ngx-dynamic-entity 1.8.0 → 1.9.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.
@@ -112,13 +112,18 @@ const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
112
112
  * The library ships no built-in implementations. Consumers provide their own list
113
113
  * so the builder's module-picker and the renderer's tab switcher know what's available.
114
114
  *
115
+ * `component` must be the component **class**: it is mounted through `ngComponentOutlet`,
116
+ * which takes a type, not a selector. An entry naming a selector string renders nothing and
117
+ * warns — `COMMON_MODULES` in core is a catalogue of names for the builder's picker, and
118
+ * offering a module the renderer cannot mount is the one way to get this wrong.
119
+ *
115
120
  * @example
116
121
  * providers: [
117
122
  * {
118
123
  * provide: COMMON_MODULES_REGISTRY,
119
124
  * useValue: [
120
- * { id: 'documents', label: { en: 'Documents' }, component: 'app-documents-view' },
121
- * { id: 'audit', label: { en: 'Audit Log' }, component: 'app-audit' },
125
+ * { id: 'documents', label: { en: 'Documents' }, component: DocumentsViewComponent },
126
+ * { id: 'audit', label: { en: 'Audit Log' }, component: AuditLogComponent },
122
127
  * ] satisfies CommonModuleEntry[]
123
128
  * }
124
129
  * ]
@@ -141,7 +146,10 @@ const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
141
146
  * @example
142
147
  * { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
143
148
  */
144
- const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', { providedIn: 'root', factory: () => new InMemoryEntityRefCacheStore() });
149
+ const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', {
150
+ providedIn: 'root',
151
+ factory: () => new InMemoryEntityRefCacheStore(),
152
+ });
145
153
  /**
146
154
  * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
147
155
  * Defaults to allowing edits if not provided.
@@ -171,6 +179,54 @@ const CONFIG_SOURCE = new InjectionToken('CONFIG_SOURCE');
171
179
  * { provide: MARKDOWN_RENDERER, useValue: (src: string) => marked.parse(src) as string }
172
180
  */
173
181
  const MARKDOWN_RENDERER = new InjectionToken('MARKDOWN_RENDERER');
182
+ /**
183
+ * The text a masked field shows in place of its value. Defaults to `XXXXXXXXX`.
184
+ *
185
+ * Masking is presentational — see SECURITY.md — and what it prints is a product decision:
186
+ * `••••••••` reads as a redaction, "Hidden" reads as a permission, and a localized string
187
+ * reads as neither in English. It was a literal repeated across every field component, so
188
+ * there was no way to choose.
189
+ *
190
+ * @example
191
+ * { provide: MASKED_PLACEHOLDER, useValue: '••••••••' }
192
+ */
193
+ const MASKED_PLACEHOLDER = new InjectionToken('MASKED_PLACEHOLDER');
194
+ /**
195
+ * Overrides for the library's own chrome — Save, Reset, "No rows yet.", and the rest.
196
+ *
197
+ * Field labels and options come from the config as `LocalizedText` and already follow the
198
+ * form's `language`. This is the text *around* them, which was English literals in the
199
+ * templates: an application in German rendered German labels around English buttons.
200
+ *
201
+ * The library does no translating — it publishes the keys it renders and resolves whatever
202
+ * a host hands back. `DEFAULT_UI_TEXT` is the full list of keys with their English source
203
+ * strings, so a translation file can be generated from it rather than transcribed. Anything
204
+ * left out keeps its English default.
205
+ *
206
+ * @example The config's own shape — `LocalizedText` per key, resolved against `language`
207
+ * {
208
+ * provide: UI_TEXT,
209
+ * useValue: {
210
+ * save: { en: 'Save', de: 'Speichern' },
211
+ * reset: { en: 'Reset', de: 'Zurücksetzen' },
212
+ * },
213
+ * }
214
+ *
215
+ * @example One language, or a host that re-provides on switch
216
+ * { provide: UI_TEXT, useValue: { save: 'Speichern', reset: 'Zurücksetzen' } }
217
+ *
218
+ * @example An existing i18n layer (ngx-translate, Transloco, $localize)
219
+ * {
220
+ * provide: UI_TEXT,
221
+ * useFactory: () => {
222
+ * const translate = inject(TranslateService);
223
+ * return (key, defaultText) => translate.instant(`dynamicEntity.${key}`) || defaultText;
224
+ * },
225
+ * }
226
+ *
227
+ * A resolver is read during change detection, so it must be synchronous and cheap.
228
+ */
229
+ const UI_TEXT = new InjectionToken('UI_TEXT');
174
230
 
175
231
  /**
176
232
  * Provider function for ngx-dynamic-entity.
@@ -195,6 +251,7 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
195
251
  { provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
196
252
  { provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
197
253
  { provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
254
+ { provide: UI_TEXT, useValue: config.uiText ?? {} },
198
255
  ]);
199
256
 
200
257
  /** The built-in English messages. Every key here can be overridden individually. */
@@ -250,7 +307,53 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
250
307
  args: [{ providedIn: 'root' }]
251
308
  }] });
252
309
 
310
+ /**
311
+ * DOM ids that survive being rendered more than once.
312
+ *
313
+ * A field component used to derive its control's id straight from `field.id`. That is unique
314
+ * in a config and *not* unique in a document: an `array` field renders the same child fields
315
+ * once per row, so a two-row Contacts array put two `#name` inputs on the page. `<label for>`
316
+ * resolves to the first match in document order, so clicking the second row's label focused
317
+ * the first row's input, and a screen reader read the same association twice. Duplicate ids on
318
+ * focusable elements are also a WCAG failure in their own right.
319
+ *
320
+ * The fix is a counter, which is what Angular Material does for the same reason: the id stays
321
+ * readable — `email-de7` — and is unique for the lifetime of the page. It is deliberately not
322
+ * derived from the row index, because a field component is given exactly five inputs and none
323
+ * of them says where it is; adding a sixth would change the contract every custom field type
324
+ * implements.
325
+ *
326
+ * Ids are not a public contract. Address a control in a test through its `data-testid`, which
327
+ * is stable, or through its label, which is what a user sees.
328
+ */
329
+ let sequence = 0;
330
+ /** A token unique to one component instance, for the lifetime of the page. */
331
+ function nextFieldInstanceId() {
332
+ sequence += 1;
333
+ return `de${sequence}`;
334
+ }
335
+ /**
336
+ * `{fieldId}-{instance}{suffix}` — the field first, so the id still reads as the field it
337
+ * belongs to when someone is looking at the DOM.
338
+ *
339
+ * `field` is undefined until the renderer assigns it, and a template can be checked once
340
+ * before that happens.
341
+ */
342
+ function fieldDomId(field, instance, suffix = '') {
343
+ return `${field?.id ?? 'field'}-${instance}${suffix}`;
344
+ }
345
+
253
346
  class TextFieldComponent {
347
+ /**
348
+ * Unique to this component instance: an `array` renders the same field once per row, and a
349
+ * DOM id may not repeat. See `field-dom-id.ts`.
350
+ */
351
+ instanceId = nextFieldInstanceId();
352
+ domId(suffix = '') {
353
+ return fieldDomId(this.field, this.instanceId, suffix);
354
+ }
355
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
356
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
254
357
  messages = inject(ValidationMessagesService);
255
358
  displaySub;
256
359
  field;
@@ -281,35 +384,56 @@ class TextFieldComponent {
281
384
  get errorMessage() {
282
385
  if (!this.control || !this.control.errors || !this.control.touched)
283
386
  return '';
284
- return this.messages.resolve(this.control.errors, this.language, ['required', 'email', 'minlength', 'maxlength', 'pattern']);
387
+ return this.messages.resolve(this.control.errors, this.language, [
388
+ 'required',
389
+ 'email',
390
+ 'minlength',
391
+ 'maxlength',
392
+ 'pattern',
393
+ ]);
285
394
  }
286
395
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
287
396
  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: `
288
397
  <div
289
398
  class="ngx-field ngx-field--text"
290
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
399
+ [attr.data-testid]="'field-' + field.id"
400
+ [attr.data-field-type]="field.type"
291
401
  [class.ngx-field--readonly]="readonly"
292
402
  [class.ngx-field--masked]="masked"
293
403
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
294
404
  >
295
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}@if (field.validators?.required) { <span class="ngx-field__req">*</span> }</label>
405
+ <label class="ngx-field__label" [attr.for]="domId()"
406
+ >{{ label }}
407
+ @if (field.validators?.required) {
408
+ <span class="ngx-field__req">*</span>
409
+ }
410
+ </label>
296
411
  @if (masked) {
297
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
412
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
413
+ maskedText
414
+ }}</span>
298
415
  } @else if (readonly) {
299
416
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
300
417
  } @else {
301
418
  <input
302
- [id]="field.id"
419
+ [id]="domId()"
303
420
  type="text"
304
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
421
+ class="ngx-field__input"
422
+ [attr.data-testid]="'field-' + field.id + '-input'"
305
423
  [formControl]="$any(control)"
306
424
  [placeholder]="placeholder"
307
425
  [attr.disabled]="field.disabled ? true : null"
308
426
  [attr.aria-invalid]="control.invalid && control.touched"
309
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
427
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
310
428
  />
311
429
  @if (errorMessage) {
312
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
430
+ <span
431
+ class="ngx-field__error"
432
+ [attr.data-testid]="'field-' + field.id + '-error'"
433
+ [id]="domId('-error')"
434
+ role="alert"
435
+ >{{ errorMessage }}</span
436
+ >
313
437
  }
314
438
  }
315
439
  </div>
@@ -325,29 +449,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
325
449
  template: `
326
450
  <div
327
451
  class="ngx-field ngx-field--text"
328
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
452
+ [attr.data-testid]="'field-' + field.id"
453
+ [attr.data-field-type]="field.type"
329
454
  [class.ngx-field--readonly]="readonly"
330
455
  [class.ngx-field--masked]="masked"
331
456
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
332
457
  >
333
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}@if (field.validators?.required) { <span class="ngx-field__req">*</span> }</label>
458
+ <label class="ngx-field__label" [attr.for]="domId()"
459
+ >{{ label }}
460
+ @if (field.validators?.required) {
461
+ <span class="ngx-field__req">*</span>
462
+ }
463
+ </label>
334
464
  @if (masked) {
335
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
465
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
466
+ maskedText
467
+ }}</span>
336
468
  } @else if (readonly) {
337
469
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
338
470
  } @else {
339
471
  <input
340
- [id]="field.id"
472
+ [id]="domId()"
341
473
  type="text"
342
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
474
+ class="ngx-field__input"
475
+ [attr.data-testid]="'field-' + field.id + '-input'"
343
476
  [formControl]="$any(control)"
344
477
  [placeholder]="placeholder"
345
478
  [attr.disabled]="field.disabled ? true : null"
346
479
  [attr.aria-invalid]="control.invalid && control.touched"
347
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
480
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
348
481
  />
349
482
  @if (errorMessage) {
350
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
483
+ <span
484
+ class="ngx-field__error"
485
+ [attr.data-testid]="'field-' + field.id + '-error'"
486
+ [id]="domId('-error')"
487
+ role="alert"
488
+ >{{ errorMessage }}</span
489
+ >
351
490
  }
352
491
  }
353
492
  </div>
@@ -366,6 +505,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
366
505
  }] } });
367
506
 
368
507
  class TextareaFieldComponent {
508
+ /**
509
+ * Unique to this component instance: an `array` renders the same field once per row, and a
510
+ * DOM id may not repeat. See `field-dom-id.ts`.
511
+ */
512
+ instanceId = nextFieldInstanceId();
513
+ domId(suffix = '') {
514
+ return fieldDomId(this.field, this.instanceId, suffix);
515
+ }
516
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
517
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
518
+ messages = inject(ValidationMessagesService);
369
519
  field;
370
520
  control;
371
521
  language = 'en';
@@ -377,26 +527,53 @@ class TextareaFieldComponent {
377
527
  get placeholder() {
378
528
  return resolveLabel(this.field?.placeholder, this.language);
379
529
  }
530
+ /**
531
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
532
+ * validationMessages })` reaches this field. It used to render a fixed
533
+ * "This field has an error", which made a documented, configurable feature work on three
534
+ * of fifteen field types.
535
+ */
536
+ get errorMessage() {
537
+ if (!this.control?.errors || !this.control.touched)
538
+ 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
+ ]);
548
+ }
380
549
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TextareaFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
381
550
  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: `
382
- <div class="ngx-field ngx-field--textarea"
383
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
384
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
551
+ <div
552
+ class="ngx-field ngx-field--textarea"
553
+ [attr.data-testid]="'field-' + field.id"
554
+ [attr.data-field-type]="field.type"
555
+ [class.ngx-field--readonly]="readonly"
556
+ [class.ngx-field--masked]="masked"
557
+ >
558
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
385
559
  @if (masked) {
386
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
560
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
561
+ maskedText
562
+ }}</span>
387
563
  } @else if (readonly) {
388
564
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
389
565
  } @else {
390
566
  <textarea
391
- [id]="field.id"
392
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
567
+ [id]="domId()"
568
+ class="ngx-field__input"
569
+ [attr.data-testid]="'field-' + field.id + '-input'"
393
570
  [formControl]="$any(control)"
394
571
  [placeholder]="placeholder"
395
572
  [attr.disabled]="field.disabled ? true : null"
396
573
  rows="3"
397
574
  ></textarea>
398
- @if (control.invalid && control.touched) {
399
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
575
+ @if (errorMessage) {
576
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
400
577
  }
401
578
  }
402
579
  </div>
@@ -410,24 +587,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
410
587
  standalone: true,
411
588
  imports: [ReactiveFormsModule],
412
589
  template: `
413
- <div class="ngx-field ngx-field--textarea"
414
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
415
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
590
+ <div
591
+ class="ngx-field ngx-field--textarea"
592
+ [attr.data-testid]="'field-' + field.id"
593
+ [attr.data-field-type]="field.type"
594
+ [class.ngx-field--readonly]="readonly"
595
+ [class.ngx-field--masked]="masked"
596
+ >
597
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
416
598
  @if (masked) {
417
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
599
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
600
+ maskedText
601
+ }}</span>
418
602
  } @else if (readonly) {
419
603
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
420
604
  } @else {
421
605
  <textarea
422
- [id]="field.id"
423
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
606
+ [id]="domId()"
607
+ class="ngx-field__input"
608
+ [attr.data-testid]="'field-' + field.id + '-input'"
424
609
  [formControl]="$any(control)"
425
610
  [placeholder]="placeholder"
426
611
  [attr.disabled]="field.disabled ? true : null"
427
612
  rows="3"
428
613
  ></textarea>
429
- @if (control.invalid && control.touched) {
430
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
614
+ @if (errorMessage) {
615
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
431
616
  }
432
617
  }
433
618
  </div>
@@ -445,6 +630,180 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
445
630
  type: Input
446
631
  }] } });
447
632
 
633
+ /** `{name}` — the slot a `UiTextParams` entry fills. */
634
+ const PLACEHOLDER = new RegExp('[{]([a-zA-Z0-9_]+)[}]', 'g');
635
+ /**
636
+ * Every word this library puts on screen that does not come from a config.
637
+ *
638
+ * Field labels, placeholders and options are `LocalizedText` and already follow the form's
639
+ * `language`. The library's *own* chrome — Save, Reset, "No rows yet." — was English
640
+ * literals in twenty-odd templates, so an application in German rendered German labels
641
+ * around English buttons. There was no way to change them short of forking a component.
642
+ *
643
+ * This map is the whole surface: the keys are what a host translates, and the values are
644
+ * the English source strings to translate *from*. It is exported so a translation file can
645
+ * be generated from it rather than transcribed by hand.
646
+ *
647
+ * Keys are grouped by where they appear rather than alphabetically, because a translator
648
+ * works screen by screen and a list sorted by name hides which strings sit together.
649
+ */
650
+ const DEFAULT_UI_TEXT = {
651
+ // Form actions
652
+ save: 'Save',
653
+ saving: 'Saving…',
654
+ reset: 'Reset',
655
+ saveShortcutHint: 'Shortcut: Ctrl+S',
656
+ loading: 'Loading…',
657
+ // Record view — the per-tab edit flow
658
+ editSection: 'Edit section',
659
+ saveSection: 'Save section',
660
+ editingSection: 'Editing this section',
661
+ cancel: 'Cancel',
662
+ edit: 'Edit',
663
+ delete: 'Delete',
664
+ // Array rows
665
+ addRow: '+ Row',
666
+ addItem: '+ Add Item',
667
+ itemNumber: 'Item #{number}',
668
+ addRowTitle: 'Add {field} row',
669
+ editRowTitle: 'Edit {field} row',
670
+ saveRow: 'Save row',
671
+ remove: 'Remove',
672
+ noRows: 'No rows yet.',
673
+ noItems: 'No items added yet.',
674
+ // Choice fields
675
+ selectPlaceholder: 'Select...',
676
+ selectParentFirst: 'Select {field} first.',
677
+ // Boolean and checkbox display
678
+ yes: 'Yes',
679
+ no: 'No',
680
+ // Files and images
681
+ uploading: 'Uploading…',
682
+ chooseFile: 'Choose file',
683
+ replaceFile: 'Replace file',
684
+ removeFile: 'Remove file',
685
+ downloadFile: 'Download file',
686
+ noImage: 'No image',
687
+ uploadImage: 'Upload',
688
+ changeImage: 'Change',
689
+ // Month / year pickers
690
+ month: 'Month',
691
+ year: 'Year',
692
+ // Markdown editor
693
+ write: 'Write',
694
+ preview: 'Preview',
695
+ markdownEditorMode: '{label} editor mode',
696
+ // Accessible names for icon-only controls
697
+ showPassword: 'Show password',
698
+ hidePassword: 'Hide password',
699
+ lockField: 'Lock {field}',
700
+ unlockField: 'Unlock {field}',
701
+ criticalFieldLockHint: 'Critical field — click to lock',
702
+ criticalFieldUnlockHint: 'Critical field — click to unlock for editing',
703
+ dismissBanner: 'Dismiss: {message}',
704
+ jumpToField: 'Click to jump to {field}',
705
+ // Banners and whole-record states
706
+ entityLabel: 'Entity: {entity}',
707
+ criticalFieldChanged: '🔒 Critical field changed: {fields} — this differs from the value at the start of this session.',
708
+ recordModified: '🔒 Record modified from session baseline — unsaved changes pending.',
709
+ accessDenied: 'You do not have permission to view this record.',
710
+ };
711
+ /**
712
+ * The resolution rule itself, over any key vocabulary.
713
+ *
714
+ * Exported because the builder package renders a second, disjoint set of words and needs
715
+ * the same rule — overrides first, then the English default, per key, with `{placeholder}`
716
+ * substitution. Sharing the function rather than the vocabulary is what keeps a renderer
717
+ * consumer from seeing a hundred and fifty builder keys in completion.
718
+ */
719
+ function resolveUiText(overrides, defaults, key, language, params) {
720
+ const fallback = ownString(defaults, key);
721
+ const resolved = !overrides
722
+ ? fallback
723
+ : typeof overrides === 'function'
724
+ ? askResolver(overrides, key, fallback, language)
725
+ : // `resolveLabel` handles a bare string at runtime but does not say so in its
726
+ // signature, so the branch is taken here rather than leaning on an undeclared one.
727
+ resolveOne(own(overrides, key), language) || fallback;
728
+ return interpolate(resolved, params);
729
+ }
730
+ /**
731
+ * An **own** entry, or `undefined`.
732
+ *
733
+ * `map[key]` walks the prototype chain, so `toString` answers with `Function` and
734
+ * `__proto__` with `Object.prototype` — neither of which is a string, and both of which
735
+ * reach `interpolate` and then a template. The key type rules that out in TypeScript; a key
736
+ * arriving from a translation catalogue at runtime, or from a host written in JavaScript,
737
+ * does not.
738
+ */
739
+ function own(map, key) {
740
+ return Object.prototype.hasOwnProperty.call(map, key) ? map[key] : undefined;
741
+ }
742
+ function ownString(defaults, key) {
743
+ const value = own(defaults, key);
744
+ return typeof value === 'string' ? value : '';
745
+ }
746
+ /**
747
+ * A resolver runs inside change detection, once per label per pass, in someone else's code.
748
+ *
749
+ * If it throws, the exception surfaces from a template expression and the form does not
750
+ * render at all — every form in the application, over one bad catalogue entry. Falling back
751
+ * to English is the same answer this function already gives for a resolver that returns
752
+ * nothing, and it keeps a translation fault a translation fault rather than an outage.
753
+ */
754
+ function askResolver(resolver, key, fallback, language) {
755
+ try {
756
+ const answer = resolver(key, fallback, language);
757
+ return typeof answer === 'string' && answer ? answer : fallback;
758
+ }
759
+ catch {
760
+ return fallback;
761
+ }
762
+ }
763
+ function resolveOne(value, language) {
764
+ if (typeof value === 'string')
765
+ return value;
766
+ const resolved = resolveLabel(value, language);
767
+ // `resolveLabel` is typed to return a string and does, for every shape it is documented
768
+ // to take. A host handing it a number reaches `Object.values(42)`, which is empty.
769
+ return typeof resolved === 'string' ? resolved : '';
770
+ }
771
+ /**
772
+ * Resolves the library's own UI text, honouring `UI_TEXT` before the defaults.
773
+ *
774
+ * Resolution is per key, not all-or-nothing, so a host replacing three buttons does not
775
+ * have to supply the other thirty — and an override that yields nothing for a key still
776
+ * renders English rather than a gap, which is what a translation catalogue asked for a
777
+ * missing entry typically hands back.
778
+ */
779
+ class UiTextService {
780
+ overrides = inject(UI_TEXT, { optional: true });
781
+ /**
782
+ * One label by key, in `language`; English if nothing overrides it. `params` fill the
783
+ * `{placeholder}` slots, in whatever position the translation puts them.
784
+ */
785
+ text(key, language = 'en', params) {
786
+ return resolveUiText(this.overrides, DEFAULT_UI_TEXT, key, language, params);
787
+ }
788
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
789
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, providedIn: 'root' });
790
+ }
791
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: UiTextService, decorators: [{
792
+ type: Injectable,
793
+ args: [{ providedIn: 'root' }]
794
+ }] });
795
+ /** A placeholder with no matching param is left as written, which is visible in review. */
796
+ function interpolate(text, params) {
797
+ if (!params)
798
+ return text;
799
+ // `name in params` would answer for `toString` and `constructor` too, substituting a
800
+ // function body into a sentence. Only what the caller actually passed counts.
801
+ return text.replace(PLACEHOLDER, (match, name) => {
802
+ const value = own(params, name);
803
+ return value === undefined || value === null ? match : String(value);
804
+ });
805
+ }
806
+
448
807
  /**
449
808
  * Markdown field: a long-form input that stores **markdown source**, never HTML.
450
809
  *
@@ -462,6 +821,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
462
821
  * the token's own note on why that is a backstop rather than a licence.
463
822
  */
464
823
  class MarkdownFieldComponent {
824
+ /**
825
+ * Unique to this component instance: an `array` renders the same field once per row, and a
826
+ * DOM id may not repeat. See `field-dom-id.ts`.
827
+ */
828
+ instanceId = nextFieldInstanceId();
829
+ domId(suffix = '') {
830
+ return fieldDomId(this.field, this.instanceId, suffix);
831
+ }
832
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
833
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
834
+ messages = inject(ValidationMessagesService);
835
+ /** Library chrome, overridable via UI_TEXT. */
836
+ ui = inject(UiTextService);
465
837
  field;
466
838
  control;
467
839
  language = 'en';
@@ -518,6 +890,25 @@ class MarkdownFieldComponent {
518
890
  return null;
519
891
  }
520
892
  }
893
+ /**
894
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
895
+ * validationMessages })` reaches this field. It used to render a fixed
896
+ * "This field has an error", which made a documented, configurable feature work on three
897
+ * of fifteen field types.
898
+ */
899
+ get errorMessage() {
900
+ if (!this.control?.errors || !this.control.touched)
901
+ 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
+ ]);
911
+ }
521
912
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MarkdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
522
913
  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: `
523
914
  <div
@@ -527,22 +918,34 @@ class MarkdownFieldComponent {
527
918
  [class.ngx-field--readonly]="readonly"
528
919
  [class.ngx-field--masked]="masked"
529
920
  >
530
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
921
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
531
922
 
532
923
  @if (masked) {
533
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
924
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
925
+ maskedText
926
+ }}</span>
534
927
  } @else if (readonly) {
535
928
  @if (rendered(); as html) {
536
- <div class="ngx-field__value ngx-field__markdown" [attr.data-testid]="'field-' + field.id + '-value'" [innerHTML]="html"></div>
929
+ <div
930
+ class="ngx-field__value ngx-field__markdown"
931
+ [attr.data-testid]="'field-' + field.id + '-value'"
932
+ [innerHTML]="html"
933
+ ></div>
537
934
  } @else {
538
935
  <!-- No renderer: the source is the display. The stylesheet keeps its line
539
936
  breaks with white-space: pre-wrap, and interpolation escapes it. -->
540
- <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
937
+ <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
938
+ control.value
939
+ }}</span>
541
940
  }
542
941
  } @else {
543
942
  <div class="ngx-field__markdown-editor">
544
943
  @if (canPreview) {
545
- <div class="ngx-field__markdown-tabs" role="group" [attr.aria-label]="label + ' editor mode'">
944
+ <div
945
+ class="ngx-field__markdown-tabs"
946
+ role="group"
947
+ [attr.aria-label]="ui.text('markdownEditorMode', language, { label })"
948
+ >
546
949
  <button
547
950
  type="button"
548
951
  class="ngx-field__markdown-tab"
@@ -550,7 +953,7 @@ class MarkdownFieldComponent {
550
953
  [attr.aria-pressed]="!previewing()"
551
954
  (click)="previewing.set(false)"
552
955
  >
553
- Write
956
+ {{ ui.text('write', language) }}
554
957
  </button>
555
958
  <button
556
959
  type="button"
@@ -559,16 +962,20 @@ class MarkdownFieldComponent {
559
962
  [attr.aria-pressed]="previewing()"
560
963
  (click)="previewing.set(true)"
561
964
  >
562
- Preview
965
+ {{ ui.text('preview', language) }}
563
966
  </button>
564
967
  </div>
565
968
  }
566
969
 
567
970
  @if (previewing()) {
568
- <div class="ngx-field__markdown ngx-field__markdown--preview" [attr.data-testid]="'field-' + field.id + '-preview-body'" [innerHTML]="rendered()"></div>
971
+ <div
972
+ class="ngx-field__markdown ngx-field__markdown--preview"
973
+ [attr.data-testid]="'field-' + field.id + '-preview-body'"
974
+ [innerHTML]="rendered()"
975
+ ></div>
569
976
  } @else {
570
977
  <textarea
571
- [id]="field.id"
978
+ [id]="domId()"
572
979
  class="ngx-field__input ngx-field__input--markdown"
573
980
  [attr.data-testid]="'field-' + field.id + '-input'"
574
981
  [formControl]="$any(control)"
@@ -579,8 +986,8 @@ class MarkdownFieldComponent {
579
986
  }
580
987
  </div>
581
988
 
582
- @if (control.invalid && control.touched) {
583
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
989
+ @if (errorMessage) {
990
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
584
991
  }
585
992
  }
586
993
  </div>
@@ -601,22 +1008,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
601
1008
  [class.ngx-field--readonly]="readonly"
602
1009
  [class.ngx-field--masked]="masked"
603
1010
  >
604
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
1011
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
605
1012
 
606
1013
  @if (masked) {
607
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1014
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1015
+ maskedText
1016
+ }}</span>
608
1017
  } @else if (readonly) {
609
1018
  @if (rendered(); as html) {
610
- <div class="ngx-field__value ngx-field__markdown" [attr.data-testid]="'field-' + field.id + '-value'" [innerHTML]="html"></div>
1019
+ <div
1020
+ class="ngx-field__value ngx-field__markdown"
1021
+ [attr.data-testid]="'field-' + field.id + '-value'"
1022
+ [innerHTML]="html"
1023
+ ></div>
611
1024
  } @else {
612
1025
  <!-- No renderer: the source is the display. The stylesheet keeps its line
613
1026
  breaks with white-space: pre-wrap, and interpolation escapes it. -->
614
- <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value }}</span>
1027
+ <span class="ngx-field__value ngx-field__markdown--source" [attr.data-testid]="'field-' + field.id + '-value'">{{
1028
+ control.value
1029
+ }}</span>
615
1030
  }
616
1031
  } @else {
617
1032
  <div class="ngx-field__markdown-editor">
618
1033
  @if (canPreview) {
619
- <div class="ngx-field__markdown-tabs" role="group" [attr.aria-label]="label + ' editor mode'">
1034
+ <div
1035
+ class="ngx-field__markdown-tabs"
1036
+ role="group"
1037
+ [attr.aria-label]="ui.text('markdownEditorMode', language, { label })"
1038
+ >
620
1039
  <button
621
1040
  type="button"
622
1041
  class="ngx-field__markdown-tab"
@@ -624,7 +1043,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
624
1043
  [attr.aria-pressed]="!previewing()"
625
1044
  (click)="previewing.set(false)"
626
1045
  >
627
- Write
1046
+ {{ ui.text('write', language) }}
628
1047
  </button>
629
1048
  <button
630
1049
  type="button"
@@ -633,16 +1052,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
633
1052
  [attr.aria-pressed]="previewing()"
634
1053
  (click)="previewing.set(true)"
635
1054
  >
636
- Preview
1055
+ {{ ui.text('preview', language) }}
637
1056
  </button>
638
1057
  </div>
639
1058
  }
640
1059
 
641
1060
  @if (previewing()) {
642
- <div class="ngx-field__markdown ngx-field__markdown--preview" [attr.data-testid]="'field-' + field.id + '-preview-body'" [innerHTML]="rendered()"></div>
1061
+ <div
1062
+ class="ngx-field__markdown ngx-field__markdown--preview"
1063
+ [attr.data-testid]="'field-' + field.id + '-preview-body'"
1064
+ [innerHTML]="rendered()"
1065
+ ></div>
643
1066
  } @else {
644
1067
  <textarea
645
- [id]="field.id"
1068
+ [id]="domId()"
646
1069
  class="ngx-field__input ngx-field__input--markdown"
647
1070
  [attr.data-testid]="'field-' + field.id + '-input'"
648
1071
  [formControl]="$any(control)"
@@ -653,8 +1076,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
653
1076
  }
654
1077
  </div>
655
1078
 
656
- @if (control.invalid && control.touched) {
657
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1079
+ @if (errorMessage) {
1080
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
658
1081
  }
659
1082
  }
660
1083
  </div>
@@ -673,6 +1096,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
673
1096
  }] } });
674
1097
 
675
1098
  class NumberFieldComponent {
1099
+ /**
1100
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1101
+ * DOM id may not repeat. See `field-dom-id.ts`.
1102
+ */
1103
+ instanceId = nextFieldInstanceId();
1104
+ domId(suffix = '') {
1105
+ return fieldDomId(this.field, this.instanceId, suffix);
1106
+ }
1107
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1108
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
676
1109
  messages = inject(ValidationMessagesService);
677
1110
  field;
678
1111
  control;
@@ -694,32 +1127,44 @@ class NumberFieldComponent {
694
1127
  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: `
695
1128
  <div
696
1129
  class="ngx-field ngx-field--number"
697
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
1130
+ [attr.data-testid]="'field-' + field.id"
1131
+ [attr.data-field-type]="field.type"
698
1132
  [class.ngx-field--readonly]="readonly"
699
1133
  [class.ngx-field--masked]="masked"
700
1134
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
701
1135
  >
702
- <label class="ngx-field__label" [attr.for]="field.id">
1136
+ <label class="ngx-field__label" [attr.for]="domId()">
703
1137
  {{ label }}
704
- @if (field.validators?.required) { <span class="ngx-field__req">*</span> }
1138
+ @if (field.validators?.required) {
1139
+ <span class="ngx-field__req">*</span>
1140
+ }
705
1141
  </label>
706
1142
  @if (masked) {
707
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1143
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1144
+ maskedText
1145
+ }}</span>
708
1146
  } @else if (readonly) {
709
1147
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
710
1148
  } @else {
711
1149
  <input
712
- [id]="field.id"
1150
+ [id]="domId()"
713
1151
  type="number"
714
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1152
+ class="ngx-field__input"
1153
+ [attr.data-testid]="'field-' + field.id + '-input'"
715
1154
  [formControl]="$any(control)"
716
1155
  [placeholder]="placeholder"
717
1156
  [attr.disabled]="field.disabled ? true : null"
718
1157
  [attr.aria-invalid]="control.invalid && control.touched"
719
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
1158
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
720
1159
  />
721
1160
  @if (errorMessage) {
722
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
1161
+ <span
1162
+ class="ngx-field__error"
1163
+ [attr.data-testid]="'field-' + field.id + '-error'"
1164
+ [id]="domId('-error')"
1165
+ role="alert"
1166
+ >{{ errorMessage }}</span
1167
+ >
723
1168
  }
724
1169
  }
725
1170
  </div>
@@ -735,32 +1180,44 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
735
1180
  template: `
736
1181
  <div
737
1182
  class="ngx-field ngx-field--number"
738
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
1183
+ [attr.data-testid]="'field-' + field.id"
1184
+ [attr.data-field-type]="field.type"
739
1185
  [class.ngx-field--readonly]="readonly"
740
1186
  [class.ngx-field--masked]="masked"
741
1187
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
742
1188
  >
743
- <label class="ngx-field__label" [attr.for]="field.id">
1189
+ <label class="ngx-field__label" [attr.for]="domId()">
744
1190
  {{ label }}
745
- @if (field.validators?.required) { <span class="ngx-field__req">*</span> }
1191
+ @if (field.validators?.required) {
1192
+ <span class="ngx-field__req">*</span>
1193
+ }
746
1194
  </label>
747
1195
  @if (masked) {
748
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1196
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1197
+ maskedText
1198
+ }}</span>
749
1199
  } @else if (readonly) {
750
1200
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
751
1201
  } @else {
752
1202
  <input
753
- [id]="field.id"
1203
+ [id]="domId()"
754
1204
  type="number"
755
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1205
+ class="ngx-field__input"
1206
+ [attr.data-testid]="'field-' + field.id + '-input'"
756
1207
  [formControl]="$any(control)"
757
1208
  [placeholder]="placeholder"
758
1209
  [attr.disabled]="field.disabled ? true : null"
759
1210
  [attr.aria-invalid]="control.invalid && control.touched"
760
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
1211
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
761
1212
  />
762
1213
  @if (errorMessage) {
763
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
1214
+ <span
1215
+ class="ngx-field__error"
1216
+ [attr.data-testid]="'field-' + field.id + '-error'"
1217
+ [id]="domId('-error')"
1218
+ role="alert"
1219
+ >{{ errorMessage }}</span
1220
+ >
764
1221
  }
765
1222
  }
766
1223
  </div>
@@ -780,6 +1237,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
780
1237
 
781
1238
  /** Currency field: number input with locale-aware currency symbol prefix. */
782
1239
  class CurrencyFieldComponent {
1240
+ /**
1241
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1242
+ * DOM id may not repeat. See `field-dom-id.ts`.
1243
+ */
1244
+ instanceId = nextFieldInstanceId();
1245
+ domId(suffix = '') {
1246
+ return fieldDomId(this.field, this.instanceId, suffix);
1247
+ }
1248
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1249
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1250
+ messages = inject(ValidationMessagesService);
783
1251
  field;
784
1252
  control;
785
1253
  language = 'en';
@@ -794,27 +1262,59 @@ class CurrencyFieldComponent {
794
1262
  /** Picks the currency symbol from the locale — defaults to '$'. */
795
1263
  get symbol() {
796
1264
  try {
797
- return (0).toLocaleString(this.language, { style: 'currency', currency: 'USD', minimumFractionDigits: 0 })
798
- .replace(/[\d,.\s]/g, '').trim() || '$';
1265
+ return ((0)
1266
+ .toLocaleString(this.language, { style: 'currency', currency: 'USD', minimumFractionDigits: 0 })
1267
+ .replace(/[\d,.\s]/g, '')
1268
+ .trim() || '$');
799
1269
  }
800
1270
  catch {
801
1271
  return '$';
802
1272
  }
803
1273
  }
1274
+ /**
1275
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
1276
+ * validationMessages })` reaches this field. It used to render a fixed
1277
+ * "This field has an error", which made a documented, configurable feature work on three
1278
+ * of fifteen field types.
1279
+ */
1280
+ get errorMessage() {
1281
+ if (!this.control?.errors || !this.control.touched)
1282
+ 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
+ ]);
1292
+ }
804
1293
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CurrencyFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
805
1294
  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: `
806
- <div class="ngx-field ngx-field--currency"
807
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
808
- <label class="ngx-field__label">{{ label }}</label>
1295
+ <div
1296
+ class="ngx-field ngx-field--currency"
1297
+ [attr.data-testid]="'field-' + field.id"
1298
+ [attr.data-field-type]="field.type"
1299
+ [class.ngx-field--readonly]="readonly"
1300
+ [class.ngx-field--masked]="masked"
1301
+ >
1302
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
809
1303
  @if (masked) {
810
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1304
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1305
+ maskedText
1306
+ }}</span>
811
1307
  } @else if (readonly) {
812
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ symbol }}{{ control.value }}</span>
1308
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
1309
+ >{{ symbol }}{{ control.value }}</span
1310
+ >
813
1311
  } @else {
814
1312
  <div class="ngx-field__currency-wrap">
815
1313
  <span class="ngx-field__currency-symbol">{{ symbol }}</span>
816
1314
  <input
817
- class="ngx-field__input ngx-field__input--currency" [attr.data-testid]="'field-' + field.id + '-input'"
1315
+ [id]="domId()"
1316
+ class="ngx-field__input ngx-field__input--currency"
1317
+ [attr.data-testid]="'field-' + field.id + '-input'"
818
1318
  type="number"
819
1319
  step="0.01"
820
1320
  [formControl]="$any(control)"
@@ -822,8 +1322,8 @@ class CurrencyFieldComponent {
822
1322
  [attr.disabled]="field.disabled ? true : null"
823
1323
  />
824
1324
  </div>
825
- @if (control.invalid && control.touched) {
826
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1325
+ @if (errorMessage) {
1326
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
827
1327
  }
828
1328
  }
829
1329
  </div>
@@ -837,18 +1337,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
837
1337
  standalone: true,
838
1338
  imports: [ReactiveFormsModule],
839
1339
  template: `
840
- <div class="ngx-field ngx-field--currency"
841
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
842
- <label class="ngx-field__label">{{ label }}</label>
1340
+ <div
1341
+ class="ngx-field ngx-field--currency"
1342
+ [attr.data-testid]="'field-' + field.id"
1343
+ [attr.data-field-type]="field.type"
1344
+ [class.ngx-field--readonly]="readonly"
1345
+ [class.ngx-field--masked]="masked"
1346
+ >
1347
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
843
1348
  @if (masked) {
844
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1349
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1350
+ maskedText
1351
+ }}</span>
845
1352
  } @else if (readonly) {
846
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ symbol }}{{ control.value }}</span>
1353
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'"
1354
+ >{{ symbol }}{{ control.value }}</span
1355
+ >
847
1356
  } @else {
848
1357
  <div class="ngx-field__currency-wrap">
849
1358
  <span class="ngx-field__currency-symbol">{{ symbol }}</span>
850
1359
  <input
851
- class="ngx-field__input ngx-field__input--currency" [attr.data-testid]="'field-' + field.id + '-input'"
1360
+ [id]="domId()"
1361
+ class="ngx-field__input ngx-field__input--currency"
1362
+ [attr.data-testid]="'field-' + field.id + '-input'"
852
1363
  type="number"
853
1364
  step="0.01"
854
1365
  [formControl]="$any(control)"
@@ -856,8 +1367,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
856
1367
  [attr.disabled]="field.disabled ? true : null"
857
1368
  />
858
1369
  </div>
859
- @if (control.invalid && control.touched) {
860
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1370
+ @if (errorMessage) {
1371
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
861
1372
  }
862
1373
  }
863
1374
  </div>
@@ -877,6 +1388,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
877
1388
 
878
1389
  /** Email field: type="email" input with inline validation hint. */
879
1390
  class EmailFieldComponent {
1391
+ /**
1392
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1393
+ * DOM id may not repeat. See `field-dom-id.ts`.
1394
+ */
1395
+ instanceId = nextFieldInstanceId();
1396
+ domId(suffix = '') {
1397
+ return fieldDomId(this.field, this.instanceId, suffix);
1398
+ }
1399
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1400
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1401
+ messages = inject(ValidationMessagesService);
880
1402
  field;
881
1403
  control;
882
1404
  language = 'en';
@@ -888,34 +1410,60 @@ class EmailFieldComponent {
888
1410
  get placeholder() {
889
1411
  return resolveLabel(this.field?.placeholder, this.language);
890
1412
  }
1413
+ /**
1414
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
1415
+ * validationMessages })` reaches this field. It used to render a fixed
1416
+ * "This field has an error", which made a documented, configurable feature work on three
1417
+ * of fifteen field types.
1418
+ */
1419
+ get errorMessage() {
1420
+ if (!this.control?.errors || !this.control.touched)
1421
+ 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
+ ]);
1431
+ }
891
1432
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EmailFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
892
1433
  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: `
893
- <div class="ngx-field ngx-field--email"
894
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
895
- <label class="ngx-field__label">{{ label }}</label>
1434
+ <div
1435
+ class="ngx-field ngx-field--email"
1436
+ [attr.data-testid]="'field-' + field.id"
1437
+ [attr.data-field-type]="field.type"
1438
+ [class.ngx-field--readonly]="readonly"
1439
+ [class.ngx-field--masked]="masked"
1440
+ >
1441
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
896
1442
  @if (masked) {
897
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1443
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1444
+ maskedText
1445
+ }}</span>
898
1446
  } @else if (readonly) {
899
1447
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
900
1448
  <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
901
1449
  </span>
902
1450
  } @else {
903
1451
  <input
904
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1452
+ [id]="domId()"
1453
+ class="ngx-field__input"
1454
+ [attr.data-testid]="'field-' + field.id + '-input'"
905
1455
  type="email"
906
1456
  autocomplete="email"
907
1457
  [formControl]="$any(control)"
908
1458
  [placeholder]="placeholder || 'you@example.com'"
909
1459
  [attr.disabled]="field.disabled ? true : null"
910
1460
  />
911
- @if (control.invalid && control.touched) {
912
- @if (control.errors?.['required']) {
913
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">Email is required</span>
914
- } @else if (control.errors?.['email']) {
915
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">Enter a valid email address</span>
916
- } @else {
917
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
918
- }
1461
+ @if (errorMessage) {
1462
+ <!-- One branch, resolved by the service. The template used to spell out "Email is
1463
+ required" and "Enter a valid email address" itself, so a host configuring
1464
+ validationMessages was overridden by markup it could not reach. The service
1465
+ defaults say the same thing. -->
1466
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
919
1467
  }
920
1468
  }
921
1469
  </div>
@@ -929,32 +1477,39 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
929
1477
  standalone: true,
930
1478
  imports: [ReactiveFormsModule],
931
1479
  template: `
932
- <div class="ngx-field ngx-field--email"
933
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
934
- <label class="ngx-field__label">{{ label }}</label>
1480
+ <div
1481
+ class="ngx-field ngx-field--email"
1482
+ [attr.data-testid]="'field-' + field.id"
1483
+ [attr.data-field-type]="field.type"
1484
+ [class.ngx-field--readonly]="readonly"
1485
+ [class.ngx-field--masked]="masked"
1486
+ >
1487
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
935
1488
  @if (masked) {
936
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1489
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1490
+ maskedText
1491
+ }}</span>
937
1492
  } @else if (readonly) {
938
1493
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
939
1494
  <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
940
1495
  </span>
941
1496
  } @else {
942
1497
  <input
943
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1498
+ [id]="domId()"
1499
+ class="ngx-field__input"
1500
+ [attr.data-testid]="'field-' + field.id + '-input'"
944
1501
  type="email"
945
1502
  autocomplete="email"
946
1503
  [formControl]="$any(control)"
947
1504
  [placeholder]="placeholder || 'you@example.com'"
948
1505
  [attr.disabled]="field.disabled ? true : null"
949
1506
  />
950
- @if (control.invalid && control.touched) {
951
- @if (control.errors?.['required']) {
952
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">Email is required</span>
953
- } @else if (control.errors?.['email']) {
954
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">Enter a valid email address</span>
955
- } @else {
956
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
957
- }
1507
+ @if (errorMessage) {
1508
+ <!-- One branch, resolved by the service. The template used to spell out "Email is
1509
+ required" and "Enter a valid email address" itself, so a host configuring
1510
+ validationMessages was overridden by markup it could not reach. The service
1511
+ defaults say the same thing. -->
1512
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
958
1513
  }
959
1514
  }
960
1515
  </div>
@@ -974,6 +1529,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
974
1529
 
975
1530
  /** Password field: masked input with show/hide eye toggle. */
976
1531
  class PasswordFieldComponent {
1532
+ /**
1533
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1534
+ * DOM id may not repeat. See `field-dom-id.ts`.
1535
+ */
1536
+ instanceId = nextFieldInstanceId();
1537
+ domId(suffix = '') {
1538
+ return fieldDomId(this.field, this.instanceId, suffix);
1539
+ }
1540
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1541
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1542
+ messages = inject(ValidationMessagesService);
1543
+ /** Library chrome, overridable via UI_TEXT. */
1544
+ ui = inject(UiTextService);
977
1545
  field;
978
1546
  control;
979
1547
  language = 'en';
@@ -989,19 +1557,47 @@ class PasswordFieldComponent {
989
1557
  toggleVisibility() {
990
1558
  this.visible.update(v => !v);
991
1559
  }
1560
+ /**
1561
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
1562
+ * validationMessages })` reaches this field. It used to render a fixed
1563
+ * "This field has an error", which made a documented, configurable feature work on three
1564
+ * of fifteen field types.
1565
+ */
1566
+ get errorMessage() {
1567
+ if (!this.control?.errors || !this.control.touched)
1568
+ 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
+ ]);
1578
+ }
992
1579
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: PasswordFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
993
1580
  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: `
994
- <div class="ngx-field ngx-field--password"
995
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
996
- <label class="ngx-field__label">{{ label }}</label>
1581
+ <div
1582
+ class="ngx-field ngx-field--password"
1583
+ [attr.data-testid]="'field-' + field.id"
1584
+ [attr.data-field-type]="field.type"
1585
+ [class.ngx-field--readonly]="readonly"
1586
+ [class.ngx-field--masked]="masked"
1587
+ >
1588
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
997
1589
  @if (masked) {
998
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1590
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1591
+ maskedText
1592
+ }}</span>
999
1593
  } @else if (readonly) {
1000
1594
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">••••••••</span>
1001
1595
  } @else {
1002
1596
  <div class="ngx-field__password-wrap">
1003
1597
  <input
1004
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1598
+ [id]="domId()"
1599
+ class="ngx-field__input"
1600
+ [attr.data-testid]="'field-' + field.id + '-input'"
1005
1601
  [type]="visible() ? 'text' : 'password'"
1006
1602
  autocomplete="current-password"
1007
1603
  [formControl]="$any(control)"
@@ -1012,13 +1608,13 @@ class PasswordFieldComponent {
1012
1608
  type="button"
1013
1609
  class="ngx-field__eye-btn"
1014
1610
  (click)="toggleVisibility()"
1015
- [attr.aria-label]="visible() ? 'Hide password' : 'Show password'"
1611
+ [attr.aria-label]="ui.text(visible() ? 'hidePassword' : 'showPassword', language)"
1016
1612
  >
1017
1613
  {{ visible() ? '🙈' : '👁' }}
1018
1614
  </button>
1019
1615
  </div>
1020
- @if (control.invalid && control.touched) {
1021
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1616
+ @if (errorMessage) {
1617
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1022
1618
  }
1023
1619
  }
1024
1620
  </div>
@@ -1032,17 +1628,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1032
1628
  standalone: true,
1033
1629
  imports: [ReactiveFormsModule],
1034
1630
  template: `
1035
- <div class="ngx-field ngx-field--password"
1036
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1037
- <label class="ngx-field__label">{{ label }}</label>
1631
+ <div
1632
+ class="ngx-field ngx-field--password"
1633
+ [attr.data-testid]="'field-' + field.id"
1634
+ [attr.data-field-type]="field.type"
1635
+ [class.ngx-field--readonly]="readonly"
1636
+ [class.ngx-field--masked]="masked"
1637
+ >
1638
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1038
1639
  @if (masked) {
1039
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1640
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1641
+ maskedText
1642
+ }}</span>
1040
1643
  } @else if (readonly) {
1041
1644
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">••••••••</span>
1042
1645
  } @else {
1043
1646
  <div class="ngx-field__password-wrap">
1044
1647
  <input
1045
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1648
+ [id]="domId()"
1649
+ class="ngx-field__input"
1650
+ [attr.data-testid]="'field-' + field.id + '-input'"
1046
1651
  [type]="visible() ? 'text' : 'password'"
1047
1652
  autocomplete="current-password"
1048
1653
  [formControl]="$any(control)"
@@ -1053,13 +1658,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1053
1658
  type="button"
1054
1659
  class="ngx-field__eye-btn"
1055
1660
  (click)="toggleVisibility()"
1056
- [attr.aria-label]="visible() ? 'Hide password' : 'Show password'"
1661
+ [attr.aria-label]="ui.text(visible() ? 'hidePassword' : 'showPassword', language)"
1057
1662
  >
1058
1663
  {{ visible() ? '🙈' : '👁' }}
1059
1664
  </button>
1060
1665
  </div>
1061
- @if (control.invalid && control.touched) {
1062
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1666
+ @if (errorMessage) {
1667
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1063
1668
  }
1064
1669
  }
1065
1670
  </div>
@@ -1078,6 +1683,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1078
1683
  }] } });
1079
1684
 
1080
1685
  class CheckboxFieldComponent {
1686
+ /**
1687
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1688
+ * DOM id may not repeat. See `field-dom-id.ts`.
1689
+ */
1690
+ instanceId = nextFieldInstanceId();
1691
+ domId(suffix = '') {
1692
+ return fieldDomId(this.field, this.instanceId, suffix);
1693
+ }
1694
+ /** Library chrome, overridable via UI_TEXT. */
1695
+ ui = inject(UiTextService);
1696
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1697
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1698
+ messages = inject(ValidationMessagesService);
1081
1699
  field;
1082
1700
  control;
1083
1701
  language = 'en';
@@ -1086,27 +1704,53 @@ class CheckboxFieldComponent {
1086
1704
  get label() {
1087
1705
  return resolveLabel(this.field?.label, this.language);
1088
1706
  }
1707
+ /**
1708
+ * Configured messages reach this field too.
1709
+ *
1710
+ * It rendered no error element at all, so a required checkbox left unticked told the user
1711
+ * nothing — and `validationMessages` could not reach a field that never displayed one.
1712
+ */
1713
+ get errorMessage() {
1714
+ if (!this.control?.errors || !this.control.touched)
1715
+ return '';
1716
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
1717
+ }
1089
1718
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: CheckboxFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1090
1719
  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: `
1091
- <div class="ngx-field ngx-field--checkbox"
1092
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1720
+ <div
1721
+ class="ngx-field ngx-field--checkbox"
1722
+ [attr.data-testid]="'field-' + field.id"
1723
+ [attr.data-field-type]="field.type"
1724
+ [class.ngx-field--readonly]="readonly"
1725
+ [class.ngx-field--masked]="masked"
1726
+ >
1093
1727
  @if (masked) {
1094
1728
  <label class="ngx-field__label">{{ label }}</label>
1095
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1729
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1730
+ maskedText
1731
+ }}</span>
1096
1732
  } @else if (readonly) {
1097
1733
  <label class="ngx-field__label">{{ label }}</label>
1098
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ? 'Yes' : 'No' }}</span>
1734
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
1735
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1736
+ }}</span>
1099
1737
  } @else {
1100
- <label class="ngx-field__label" [attr.for]="field.id">
1738
+ <label class="ngx-field__label" [attr.for]="domId()">
1101
1739
  <input
1102
- [id]="field.id"
1103
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1740
+ [id]="domId()"
1741
+ class="ngx-field__input"
1742
+ [attr.data-testid]="'field-' + field.id + '-input'"
1104
1743
  type="checkbox"
1105
1744
  [formControl]="$any(control)"
1106
1745
  [attr.disabled]="field.disabled ? true : null"
1107
1746
  />
1108
1747
  {{ label }}
1109
1748
  </label>
1749
+ @if (errorMessage) {
1750
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1751
+ errorMessage
1752
+ }}</span>
1753
+ }
1110
1754
  }
1111
1755
  </div>
1112
1756
  `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -1119,25 +1763,40 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1119
1763
  standalone: true,
1120
1764
  imports: [ReactiveFormsModule],
1121
1765
  template: `
1122
- <div class="ngx-field ngx-field--checkbox"
1123
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1766
+ <div
1767
+ class="ngx-field ngx-field--checkbox"
1768
+ [attr.data-testid]="'field-' + field.id"
1769
+ [attr.data-field-type]="field.type"
1770
+ [class.ngx-field--readonly]="readonly"
1771
+ [class.ngx-field--masked]="masked"
1772
+ >
1124
1773
  @if (masked) {
1125
1774
  <label class="ngx-field__label">{{ label }}</label>
1126
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1775
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1776
+ maskedText
1777
+ }}</span>
1127
1778
  } @else if (readonly) {
1128
1779
  <label class="ngx-field__label">{{ label }}</label>
1129
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ? 'Yes' : 'No' }}</span>
1780
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
1781
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1782
+ }}</span>
1130
1783
  } @else {
1131
- <label class="ngx-field__label" [attr.for]="field.id">
1784
+ <label class="ngx-field__label" [attr.for]="domId()">
1132
1785
  <input
1133
- [id]="field.id"
1134
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1786
+ [id]="domId()"
1787
+ class="ngx-field__input"
1788
+ [attr.data-testid]="'field-' + field.id + '-input'"
1135
1789
  type="checkbox"
1136
1790
  [formControl]="$any(control)"
1137
1791
  [attr.disabled]="field.disabled ? true : null"
1138
1792
  />
1139
1793
  {{ label }}
1140
1794
  </label>
1795
+ @if (errorMessage) {
1796
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1797
+ errorMessage
1798
+ }}</span>
1799
+ }
1141
1800
  }
1142
1801
  </div>
1143
1802
  `,
@@ -1160,6 +1819,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1160
1819
  * toggle switch with Yes/No labels for clearer binary state communication.
1161
1820
  */
1162
1821
  class BooleanFieldComponent {
1822
+ /** Library chrome, overridable via UI_TEXT. */
1823
+ ui = inject(UiTextService);
1824
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1825
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1826
+ messages = inject(ValidationMessagesService);
1163
1827
  field;
1164
1828
  control;
1165
1829
  language = 'en';
@@ -1168,16 +1832,36 @@ class BooleanFieldComponent {
1168
1832
  get label() {
1169
1833
  return resolveLabel(this.field?.label, this.language);
1170
1834
  }
1835
+ /**
1836
+ * Configured messages reach this field too.
1837
+ *
1838
+ * It rendered no error element at all, so a required checkbox left unticked told the user
1839
+ * nothing — and `validationMessages` could not reach a field that never displayed one.
1840
+ */
1841
+ get errorMessage() {
1842
+ if (!this.control?.errors || !this.control.touched)
1843
+ return '';
1844
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
1845
+ }
1171
1846
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BooleanFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1172
1847
  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: `
1173
- <div class="ngx-field ngx-field--boolean"
1174
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1848
+ <div
1849
+ class="ngx-field ngx-field--boolean"
1850
+ [attr.data-testid]="'field-' + field.id"
1851
+ [attr.data-field-type]="field.type"
1852
+ [class.ngx-field--readonly]="readonly"
1853
+ [class.ngx-field--masked]="masked"
1854
+ >
1175
1855
  @if (masked) {
1176
1856
  <label class="ngx-field__label">{{ label }}</label>
1177
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1857
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1858
+ maskedText
1859
+ }}</span>
1178
1860
  } @else if (readonly) {
1179
1861
  <label class="ngx-field__label">{{ label }}</label>
1180
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ? 'Yes' : 'No' }}</span>
1862
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
1863
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1864
+ }}</span>
1181
1865
  } @else {
1182
1866
  <div class="ngx-field__toggle-wrap">
1183
1867
  <label class="ngx-field__toggle-label">{{ label }}</label>
@@ -1193,9 +1877,16 @@ class BooleanFieldComponent {
1193
1877
  <span class="ngx-field__toggle-track">
1194
1878
  <span class="ngx-field__toggle-thumb"></span>
1195
1879
  </span>
1196
- <span class="ngx-field__toggle-text">{{ control.value ? 'Yes' : 'No' }}</span>
1880
+ <span class="ngx-field__toggle-text">{{
1881
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1882
+ }}</span>
1197
1883
  </label>
1198
1884
  </div>
1885
+ @if (errorMessage) {
1886
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1887
+ errorMessage
1888
+ }}</span>
1889
+ }
1199
1890
  }
1200
1891
  </div>
1201
1892
  `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
@@ -1208,14 +1899,23 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1208
1899
  standalone: true,
1209
1900
  imports: [ReactiveFormsModule],
1210
1901
  template: `
1211
- <div class="ngx-field ngx-field--boolean"
1212
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1902
+ <div
1903
+ class="ngx-field ngx-field--boolean"
1904
+ [attr.data-testid]="'field-' + field.id"
1905
+ [attr.data-field-type]="field.type"
1906
+ [class.ngx-field--readonly]="readonly"
1907
+ [class.ngx-field--masked]="masked"
1908
+ >
1213
1909
  @if (masked) {
1214
1910
  <label class="ngx-field__label">{{ label }}</label>
1215
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1911
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
1912
+ maskedText
1913
+ }}</span>
1216
1914
  } @else if (readonly) {
1217
1915
  <label class="ngx-field__label">{{ label }}</label>
1218
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ? 'Yes' : 'No' }}</span>
1916
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
1917
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1918
+ }}</span>
1219
1919
  } @else {
1220
1920
  <div class="ngx-field__toggle-wrap">
1221
1921
  <label class="ngx-field__toggle-label">{{ label }}</label>
@@ -1231,9 +1931,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1231
1931
  <span class="ngx-field__toggle-track">
1232
1932
  <span class="ngx-field__toggle-thumb"></span>
1233
1933
  </span>
1234
- <span class="ngx-field__toggle-text">{{ control.value ? 'Yes' : 'No' }}</span>
1934
+ <span class="ngx-field__toggle-text">{{
1935
+ control.value ? ui.text('yes', language) : ui.text('no', language)
1936
+ }}</span>
1235
1937
  </label>
1236
1938
  </div>
1939
+ @if (errorMessage) {
1940
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
1941
+ errorMessage
1942
+ }}</span>
1943
+ }
1237
1944
  }
1238
1945
  </div>
1239
1946
  `,
@@ -1251,6 +1958,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1251
1958
  }] } });
1252
1959
 
1253
1960
  class DateFieldComponent {
1961
+ /**
1962
+ * Unique to this component instance: an `array` renders the same field once per row, and a
1963
+ * DOM id may not repeat. See `field-dom-id.ts`.
1964
+ */
1965
+ instanceId = nextFieldInstanceId();
1966
+ domId(suffix = '') {
1967
+ return fieldDomId(this.field, this.instanceId, suffix);
1968
+ }
1969
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
1970
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1971
+ messages = inject(ValidationMessagesService);
1254
1972
  field;
1255
1973
  control;
1256
1974
  language = 'en';
@@ -1270,25 +1988,54 @@ class DateFieldComponent {
1270
1988
  const parsed = new Date(value);
1271
1989
  return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleDateString();
1272
1990
  }
1991
+ /**
1992
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
1993
+ * validationMessages })` reaches this field. It used to render a fixed
1994
+ * "This field has an error", which made a documented, configurable feature work on three
1995
+ * of fifteen field types.
1996
+ */
1997
+ get errorMessage() {
1998
+ if (!this.control?.errors || !this.control.touched)
1999
+ return '';
2000
+ return this.messages.resolve(this.control.errors, this.language, [
2001
+ 'required',
2002
+ 'email',
2003
+ 'min',
2004
+ 'max',
2005
+ 'minlength',
2006
+ 'maxlength',
2007
+ 'pattern',
2008
+ ]);
2009
+ }
1273
2010
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1274
2011
  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: `
1275
- <div class="ngx-field ngx-field--date"
1276
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1277
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2012
+ <div
2013
+ class="ngx-field ngx-field--date"
2014
+ [attr.data-testid]="'field-' + field.id"
2015
+ [attr.data-field-type]="field.type"
2016
+ [class.ngx-field--readonly]="readonly"
2017
+ [class.ngx-field--masked]="masked"
2018
+ >
2019
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1278
2020
  @if (masked) {
1279
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2021
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2022
+ maskedText
2023
+ }}</span>
1280
2024
  } @else if (readonly) {
1281
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDate(control.value) }}</span>
2025
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2026
+ formatDate(control.value)
2027
+ }}</span>
1282
2028
  } @else {
1283
2029
  <input
1284
- [id]="field.id"
1285
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2030
+ [id]="domId()"
2031
+ class="ngx-field__input"
2032
+ [attr.data-testid]="'field-' + field.id + '-input'"
1286
2033
  type="date"
1287
2034
  [formControl]="$any(control)"
1288
2035
  [attr.disabled]="field.disabled ? true : null"
1289
2036
  />
1290
- @if (control.invalid && control.touched) {
1291
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2037
+ @if (errorMessage) {
2038
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1292
2039
  }
1293
2040
  }
1294
2041
  </div>
@@ -1302,23 +2049,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1302
2049
  standalone: true,
1303
2050
  imports: [ReactiveFormsModule],
1304
2051
  template: `
1305
- <div class="ngx-field ngx-field--date"
1306
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1307
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2052
+ <div
2053
+ class="ngx-field ngx-field--date"
2054
+ [attr.data-testid]="'field-' + field.id"
2055
+ [attr.data-field-type]="field.type"
2056
+ [class.ngx-field--readonly]="readonly"
2057
+ [class.ngx-field--masked]="masked"
2058
+ >
2059
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1308
2060
  @if (masked) {
1309
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2061
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2062
+ maskedText
2063
+ }}</span>
1310
2064
  } @else if (readonly) {
1311
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDate(control.value) }}</span>
2065
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2066
+ formatDate(control.value)
2067
+ }}</span>
1312
2068
  } @else {
1313
2069
  <input
1314
- [id]="field.id"
1315
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2070
+ [id]="domId()"
2071
+ class="ngx-field__input"
2072
+ [attr.data-testid]="'field-' + field.id + '-input'"
1316
2073
  type="date"
1317
2074
  [formControl]="$any(control)"
1318
2075
  [attr.disabled]="field.disabled ? true : null"
1319
2076
  />
1320
- @if (control.invalid && control.touched) {
1321
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2077
+ @if (errorMessage) {
2078
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1322
2079
  }
1323
2080
  }
1324
2081
  </div>
@@ -1365,6 +2122,17 @@ const pad = (n) => String(n).padStart(2, '0');
1365
2122
  * conversions here are what keep a value stable across a round trip.
1366
2123
  */
1367
2124
  class DateTimeFieldComponent {
2125
+ /**
2126
+ * Unique to this component instance: an `array` renders the same field once per row, and a
2127
+ * DOM id may not repeat. See `field-dom-id.ts`.
2128
+ */
2129
+ instanceId = nextFieldInstanceId();
2130
+ domId(suffix = '') {
2131
+ return fieldDomId(this.field, this.instanceId, suffix);
2132
+ }
2133
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
2134
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
2135
+ messages = inject(ValidationMessagesService);
1368
2136
  field;
1369
2137
  control;
1370
2138
  language = 'en';
@@ -1402,26 +2170,55 @@ class DateTimeFieldComponent {
1402
2170
  const d = parseStored(value);
1403
2171
  return d ? d.toLocaleString() : '—';
1404
2172
  }
2173
+ /**
2174
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
2175
+ * validationMessages })` reaches this field. It used to render a fixed
2176
+ * "This field has an error", which made a documented, configurable feature work on three
2177
+ * of fifteen field types.
2178
+ */
2179
+ get errorMessage() {
2180
+ if (!this.control?.errors || !this.control.touched)
2181
+ return '';
2182
+ return this.messages.resolve(this.control.errors, this.language, [
2183
+ 'required',
2184
+ 'email',
2185
+ 'min',
2186
+ 'max',
2187
+ 'minlength',
2188
+ 'maxlength',
2189
+ 'pattern',
2190
+ ]);
2191
+ }
1405
2192
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1406
2193
  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: `
1407
- <div class="ngx-field ngx-field--datetime"
1408
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1409
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2194
+ <div
2195
+ class="ngx-field ngx-field--datetime"
2196
+ [attr.data-testid]="'field-' + field.id"
2197
+ [attr.data-field-type]="field.type"
2198
+ [class.ngx-field--readonly]="readonly"
2199
+ [class.ngx-field--masked]="masked"
2200
+ >
2201
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1410
2202
  @if (masked) {
1411
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2203
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2204
+ maskedText
2205
+ }}</span>
1412
2206
  } @else if (readonly) {
1413
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
2207
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2208
+ formatDateTime(control.value)
2209
+ }}</span>
1414
2210
  } @else {
1415
2211
  <input
1416
- [id]="field.id"
1417
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2212
+ [id]="domId()"
2213
+ class="ngx-field__input"
2214
+ [attr.data-testid]="'field-' + field.id + '-input'"
1418
2215
  type="datetime-local"
1419
2216
  [value]="inputValue"
1420
2217
  (change)="onInput($any($event.target).value)"
1421
2218
  [attr.disabled]="field.disabled ? true : null"
1422
2219
  />
1423
- @if (control.invalid && control.touched) {
1424
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2220
+ @if (errorMessage) {
2221
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1425
2222
  }
1426
2223
  }
1427
2224
  </div>
@@ -1435,24 +2232,34 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1435
2232
  standalone: true,
1436
2233
  imports: [ReactiveFormsModule],
1437
2234
  template: `
1438
- <div class="ngx-field ngx-field--datetime"
1439
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1440
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2235
+ <div
2236
+ class="ngx-field ngx-field--datetime"
2237
+ [attr.data-testid]="'field-' + field.id"
2238
+ [attr.data-field-type]="field.type"
2239
+ [class.ngx-field--readonly]="readonly"
2240
+ [class.ngx-field--masked]="masked"
2241
+ >
2242
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1441
2243
  @if (masked) {
1442
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2244
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2245
+ maskedText
2246
+ }}</span>
1443
2247
  } @else if (readonly) {
1444
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
2248
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2249
+ formatDateTime(control.value)
2250
+ }}</span>
1445
2251
  } @else {
1446
2252
  <input
1447
- [id]="field.id"
1448
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2253
+ [id]="domId()"
2254
+ class="ngx-field__input"
2255
+ [attr.data-testid]="'field-' + field.id + '-input'"
1449
2256
  type="datetime-local"
1450
2257
  [value]="inputValue"
1451
2258
  (change)="onInput($any($event.target).value)"
1452
2259
  [attr.disabled]="field.disabled ? true : null"
1453
2260
  />
1454
- @if (control.invalid && control.touched) {
1455
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2261
+ @if (errorMessage) {
2262
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1456
2263
  }
1457
2264
  }
1458
2265
  </div>
@@ -1479,6 +2286,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1479
2286
  * and converting it to UTC would move it whenever the offset changed.
1480
2287
  */
1481
2288
  class TimeFieldComponent {
2289
+ /**
2290
+ * Unique to this component instance: an `array` renders the same field once per row, and a
2291
+ * DOM id may not repeat. See `field-dom-id.ts`.
2292
+ */
2293
+ instanceId = nextFieldInstanceId();
2294
+ domId(suffix = '') {
2295
+ return fieldDomId(this.field, this.instanceId, suffix);
2296
+ }
2297
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
2298
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
2299
+ messages = inject(ValidationMessagesService);
1482
2300
  field;
1483
2301
  control;
1484
2302
  language = 'en';
@@ -1495,25 +2313,54 @@ class TimeFieldComponent {
1495
2313
  formatTime(value) {
1496
2314
  return formatDisplayValue('time', undefined, value, this.language);
1497
2315
  }
2316
+ /**
2317
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
2318
+ * validationMessages })` reaches this field. It used to render a fixed
2319
+ * "This field has an error", which made a documented, configurable feature work on three
2320
+ * of fifteen field types.
2321
+ */
2322
+ get errorMessage() {
2323
+ if (!this.control?.errors || !this.control.touched)
2324
+ return '';
2325
+ return this.messages.resolve(this.control.errors, this.language, [
2326
+ 'required',
2327
+ 'email',
2328
+ 'min',
2329
+ 'max',
2330
+ 'minlength',
2331
+ 'maxlength',
2332
+ 'pattern',
2333
+ ]);
2334
+ }
1498
2335
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1499
2336
  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: `
1500
- <div class="ngx-field ngx-field--time"
1501
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1502
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2337
+ <div
2338
+ class="ngx-field ngx-field--time"
2339
+ [attr.data-testid]="'field-' + field.id"
2340
+ [attr.data-field-type]="field.type"
2341
+ [class.ngx-field--readonly]="readonly"
2342
+ [class.ngx-field--masked]="masked"
2343
+ >
2344
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1503
2345
  @if (masked) {
1504
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2346
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2347
+ maskedText
2348
+ }}</span>
1505
2349
  } @else if (readonly) {
1506
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
2350
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2351
+ formatTime(control.value)
2352
+ }}</span>
1507
2353
  } @else {
1508
2354
  <input
1509
- [id]="field.id"
1510
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2355
+ [id]="domId()"
2356
+ class="ngx-field__input"
2357
+ [attr.data-testid]="'field-' + field.id + '-input'"
1511
2358
  type="time"
1512
2359
  [formControl]="$any(control)"
1513
2360
  [attr.disabled]="field.disabled ? true : null"
1514
2361
  />
1515
- @if (control.invalid && control.touched) {
1516
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2362
+ @if (errorMessage) {
2363
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1517
2364
  }
1518
2365
  }
1519
2366
  </div>
@@ -1527,23 +2374,33 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1527
2374
  standalone: true,
1528
2375
  imports: [ReactiveFormsModule],
1529
2376
  template: `
1530
- <div class="ngx-field ngx-field--time"
1531
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1532
- <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
2377
+ <div
2378
+ class="ngx-field ngx-field--time"
2379
+ [attr.data-testid]="'field-' + field.id"
2380
+ [attr.data-field-type]="field.type"
2381
+ [class.ngx-field--readonly]="readonly"
2382
+ [class.ngx-field--masked]="masked"
2383
+ >
2384
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
1533
2385
  @if (masked) {
1534
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2386
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2387
+ maskedText
2388
+ }}</span>
1535
2389
  } @else if (readonly) {
1536
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
2390
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2391
+ formatTime(control.value)
2392
+ }}</span>
1537
2393
  } @else {
1538
2394
  <input
1539
- [id]="field.id"
1540
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2395
+ [id]="domId()"
2396
+ class="ngx-field__input"
2397
+ [attr.data-testid]="'field-' + field.id + '-input'"
1541
2398
  type="time"
1542
2399
  [formControl]="$any(control)"
1543
2400
  [attr.disabled]="field.disabled ? true : null"
1544
2401
  />
1545
- @if (control.invalid && control.touched) {
1546
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2402
+ @if (errorMessage) {
2403
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1547
2404
  }
1548
2405
  }
1549
2406
  </div>
@@ -1562,8 +2419,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1562
2419
  }] } });
1563
2420
 
1564
2421
  const MONTH_NAMES = [
1565
- 'January', 'February', 'March', 'April', 'May', 'June',
1566
- 'July', 'August', 'September', 'October', 'November', 'December',
2422
+ 'January',
2423
+ 'February',
2424
+ 'March',
2425
+ 'April',
2426
+ 'May',
2427
+ 'June',
2428
+ 'July',
2429
+ 'August',
2430
+ 'September',
2431
+ 'October',
2432
+ 'November',
2433
+ 'December',
1567
2434
  ];
1568
2435
  const CURRENT_YEAR = new Date().getFullYear();
1569
2436
  const YEARS = Array.from({ length: 80 }, (_, i) => CURRENT_YEAR - i);
@@ -1572,6 +2439,19 @@ const YEARS = Array.from({ length: 80 }, (_, i) => CURRENT_YEAR - i);
1572
2439
  * Value stored as ISO partial date string "YYYY-MM" for unambiguous storage.
1573
2440
  */
1574
2441
  class MonthYearFieldComponent {
2442
+ /**
2443
+ * Unique to this component instance: an `array` renders the same field once per row, and a
2444
+ * DOM id may not repeat. See `field-dom-id.ts`.
2445
+ */
2446
+ instanceId = nextFieldInstanceId();
2447
+ domId(suffix = '') {
2448
+ return fieldDomId(this.field, this.instanceId, suffix);
2449
+ }
2450
+ /** Library chrome, overridable via UI_TEXT. */
2451
+ ui = inject(UiTextService);
2452
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
2453
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
2454
+ messages = inject(ValidationMessagesService);
1575
2455
  field;
1576
2456
  control;
1577
2457
  language = 'en';
@@ -1608,42 +2488,82 @@ class MonthYearFieldComponent {
1608
2488
  const monthName = MONTH_NAMES[parseInt(monthNum, 10) - 1];
1609
2489
  return monthName && year ? `${monthName} ${year}` : String(value);
1610
2490
  }
2491
+ /**
2492
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
2493
+ * validationMessages })` reaches this field. It used to render a fixed
2494
+ * "This field has an error", which made a documented, configurable feature work on three
2495
+ * of fifteen field types.
2496
+ */
2497
+ get errorMessage() {
2498
+ if (!this.control?.errors || !this.control.touched)
2499
+ return '';
2500
+ return this.messages.resolve(this.control.errors, this.language, [
2501
+ 'required',
2502
+ 'email',
2503
+ 'min',
2504
+ 'max',
2505
+ 'minlength',
2506
+ 'maxlength',
2507
+ 'pattern',
2508
+ ]);
2509
+ }
1611
2510
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MonthYearFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1612
2511
  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: `
1613
- <div class="ngx-field ngx-field--month-year"
1614
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1615
- <label class="ngx-field__label">{{ label }}</label>
2512
+ <div
2513
+ class="ngx-field ngx-field--month-year"
2514
+ [attr.data-testid]="'field-' + field.id"
2515
+ [attr.data-field-type]="field.type"
2516
+ [class.ngx-field--readonly]="readonly"
2517
+ [class.ngx-field--masked]="masked"
2518
+ >
2519
+ <!--
2520
+ Points at the month select: one visible label stands in front of two controls, and a
2521
+ label pointing at neither of them announces as nothing and focuses nothing when
2522
+ clicked. Each select then carries its own name below, because "Billing Cycle" alone
2523
+ does not say which half of the pair the screen reader has landed on.
2524
+ -->
2525
+ <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
1616
2526
  @if (masked) {
1617
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2527
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2528
+ maskedText
2529
+ }}</span>
1618
2530
  } @else if (readonly) {
1619
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatValue(control.value) }}</span>
2531
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2532
+ formatValue(control.value)
2533
+ }}</span>
1620
2534
  } @else {
1621
2535
  <div class="ngx-field__month-year-wrap">
1622
2536
  <select
1623
- class="ngx-field__input ngx-field__input--month" [attr.data-testid]="'field-' + field.id + '-month'"
2537
+ [id]="domId('-month')"
2538
+ class="ngx-field__input ngx-field__input--month"
2539
+ [attr.aria-label]="label + ' ' + ui.text('month', language)"
2540
+ [attr.data-testid]="'field-' + field.id + '-month'"
1624
2541
  [value]="selectedMonth"
1625
2542
  (change)="onMonthChange($any($event.target).value)"
1626
2543
  [attr.disabled]="field.disabled ? true : null"
1627
2544
  >
1628
- <option value="">Month</option>
2545
+ <option value="">{{ ui.text('month', language) }}</option>
1629
2546
  @for (m of months; track m.value) {
1630
2547
  <option [value]="m.value">{{ m.label }}</option>
1631
2548
  }
1632
2549
  </select>
1633
2550
  <select
1634
- class="ngx-field__input ngx-field__input--year" [attr.data-testid]="'field-' + field.id + '-year'"
2551
+ [id]="domId('-year')"
2552
+ class="ngx-field__input ngx-field__input--year"
2553
+ [attr.aria-label]="label + ' ' + ui.text('year', language)"
2554
+ [attr.data-testid]="'field-' + field.id + '-year'"
1635
2555
  [value]="selectedYear"
1636
2556
  (change)="onYearChange($any($event.target).value)"
1637
2557
  [attr.disabled]="field.disabled ? true : null"
1638
2558
  >
1639
- <option value="">Year</option>
2559
+ <option value="">{{ ui.text('year', language) }}</option>
1640
2560
  @for (y of years; track y) {
1641
2561
  <option [value]="y">{{ y }}</option>
1642
2562
  }
1643
2563
  </select>
1644
2564
  </div>
1645
- @if (control.invalid && control.touched) {
1646
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2565
+ @if (errorMessage) {
2566
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1647
2567
  }
1648
2568
  }
1649
2569
  </div>
@@ -1657,40 +2577,61 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1657
2577
  standalone: true,
1658
2578
  imports: [ReactiveFormsModule],
1659
2579
  template: `
1660
- <div class="ngx-field ngx-field--month-year"
1661
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1662
- <label class="ngx-field__label">{{ label }}</label>
2580
+ <div
2581
+ class="ngx-field ngx-field--month-year"
2582
+ [attr.data-testid]="'field-' + field.id"
2583
+ [attr.data-field-type]="field.type"
2584
+ [class.ngx-field--readonly]="readonly"
2585
+ [class.ngx-field--masked]="masked"
2586
+ >
2587
+ <!--
2588
+ Points at the month select: one visible label stands in front of two controls, and a
2589
+ label pointing at neither of them announces as nothing and focuses nothing when
2590
+ clicked. Each select then carries its own name below, because "Billing Cycle" alone
2591
+ does not say which half of the pair the screen reader has landed on.
2592
+ -->
2593
+ <label class="ngx-field__label" [attr.for]="domId('-month')">{{ label }}</label>
1663
2594
  @if (masked) {
1664
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2595
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2596
+ maskedText
2597
+ }}</span>
1665
2598
  } @else if (readonly) {
1666
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatValue(control.value) }}</span>
2599
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2600
+ formatValue(control.value)
2601
+ }}</span>
1667
2602
  } @else {
1668
2603
  <div class="ngx-field__month-year-wrap">
1669
2604
  <select
1670
- class="ngx-field__input ngx-field__input--month" [attr.data-testid]="'field-' + field.id + '-month'"
2605
+ [id]="domId('-month')"
2606
+ class="ngx-field__input ngx-field__input--month"
2607
+ [attr.aria-label]="label + ' ' + ui.text('month', language)"
2608
+ [attr.data-testid]="'field-' + field.id + '-month'"
1671
2609
  [value]="selectedMonth"
1672
2610
  (change)="onMonthChange($any($event.target).value)"
1673
2611
  [attr.disabled]="field.disabled ? true : null"
1674
2612
  >
1675
- <option value="">Month</option>
2613
+ <option value="">{{ ui.text('month', language) }}</option>
1676
2614
  @for (m of months; track m.value) {
1677
2615
  <option [value]="m.value">{{ m.label }}</option>
1678
2616
  }
1679
2617
  </select>
1680
2618
  <select
1681
- class="ngx-field__input ngx-field__input--year" [attr.data-testid]="'field-' + field.id + '-year'"
2619
+ [id]="domId('-year')"
2620
+ class="ngx-field__input ngx-field__input--year"
2621
+ [attr.aria-label]="label + ' ' + ui.text('year', language)"
2622
+ [attr.data-testid]="'field-' + field.id + '-year'"
1682
2623
  [value]="selectedYear"
1683
2624
  (change)="onYearChange($any($event.target).value)"
1684
2625
  [attr.disabled]="field.disabled ? true : null"
1685
2626
  >
1686
- <option value="">Year</option>
2627
+ <option value="">{{ ui.text('year', language) }}</option>
1687
2628
  @for (y of years; track y) {
1688
2629
  <option [value]="y">{{ y }}</option>
1689
2630
  }
1690
2631
  </select>
1691
2632
  </div>
1692
- @if (control.invalid && control.touched) {
1693
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
2633
+ @if (errorMessage) {
2634
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
1694
2635
  }
1695
2636
  }
1696
2637
  </div>
@@ -1951,6 +2892,18 @@ function refreshChoiceOptions(host, lookups) {
1951
2892
  }
1952
2893
 
1953
2894
  class DropdownFieldComponent {
2895
+ /**
2896
+ * Unique to this component instance: an `array` renders the same field once per row, and a
2897
+ * DOM id may not repeat. See `field-dom-id.ts`.
2898
+ */
2899
+ instanceId = nextFieldInstanceId();
2900
+ domId(suffix = '') {
2901
+ return fieldDomId(this.field, this.instanceId, suffix);
2902
+ }
2903
+ /** Library chrome, overridable via UI_TEXT. */
2904
+ ui = inject(UiTextService);
2905
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
2906
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
1954
2907
  lookups = inject(LookupRegistryService);
1955
2908
  messages = inject(ValidationMessagesService);
1956
2909
  _field;
@@ -2019,30 +2972,38 @@ class DropdownFieldComponent {
2019
2972
  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: `
2020
2973
  <div
2021
2974
  class="ngx-field ngx-field--dropdown"
2022
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
2975
+ [attr.data-testid]="'field-' + field.id"
2976
+ [attr.data-field-type]="field.type"
2023
2977
  [class.ngx-field--readonly]="readonly"
2024
2978
  [class.ngx-field--masked]="masked"
2025
2979
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
2026
2980
  >
2027
- <label class="ngx-field__label" [attr.for]="field.id">
2981
+ <label class="ngx-field__label" [attr.for]="domId()">
2028
2982
  {{ label }}
2029
- @if (field.validators?.required) { <span class="ngx-field__req">*</span> }
2983
+ @if (field.validators?.required) {
2984
+ <span class="ngx-field__req">*</span>
2985
+ }
2030
2986
  </label>
2031
2987
  @if (masked) {
2032
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
2988
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
2989
+ maskedText
2990
+ }}</span>
2033
2991
  } @else if (readonly) {
2034
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabel(control.value) }}</span>
2992
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
2993
+ getLabel(control.value)
2994
+ }}</span>
2035
2995
  } @else {
2036
2996
  <select
2037
- [id]="field.id"
2038
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2997
+ [id]="domId()"
2998
+ class="ngx-field__input"
2999
+ [attr.data-testid]="'field-' + field.id + '-input'"
2039
3000
  [formControl]="$any(control)"
2040
3001
  [compareWith]="compareFn"
2041
3002
  [attr.disabled]="field.disabled ? true : null"
2042
3003
  [attr.aria-invalid]="control.invalid && control.touched"
2043
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
3004
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
2044
3005
  >
2045
- <option [value]="''">{{ placeholder || 'Select...' }}</option>
3006
+ <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
2046
3007
  @for (option of options(); track getOptLabel(option)) {
2047
3008
  @if (isObjectVal(option)) {
2048
3009
  <option [ngValue]="getOptStoredVal(option)">{{ getOptLabel(option) }}</option>
@@ -2052,7 +3013,13 @@ class DropdownFieldComponent {
2052
3013
  }
2053
3014
  </select>
2054
3015
  @if (errorMessage) {
2055
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
3016
+ <span
3017
+ class="ngx-field__error"
3018
+ [attr.data-testid]="'field-' + field.id + '-error'"
3019
+ [id]="domId('-error')"
3020
+ role="alert"
3021
+ >{{ errorMessage }}</span
3022
+ >
2056
3023
  }
2057
3024
  }
2058
3025
  </div>
@@ -2068,30 +3035,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2068
3035
  template: `
2069
3036
  <div
2070
3037
  class="ngx-field ngx-field--dropdown"
2071
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type"
3038
+ [attr.data-testid]="'field-' + field.id"
3039
+ [attr.data-field-type]="field.type"
2072
3040
  [class.ngx-field--readonly]="readonly"
2073
3041
  [class.ngx-field--masked]="masked"
2074
3042
  [class.ngx-field--invalid]="control && control.invalid && control.touched"
2075
3043
  >
2076
- <label class="ngx-field__label" [attr.for]="field.id">
3044
+ <label class="ngx-field__label" [attr.for]="domId()">
2077
3045
  {{ label }}
2078
- @if (field.validators?.required) { <span class="ngx-field__req">*</span> }
3046
+ @if (field.validators?.required) {
3047
+ <span class="ngx-field__req">*</span>
3048
+ }
2079
3049
  </label>
2080
3050
  @if (masked) {
2081
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3051
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3052
+ maskedText
3053
+ }}</span>
2082
3054
  } @else if (readonly) {
2083
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabel(control.value) }}</span>
3055
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
3056
+ getLabel(control.value)
3057
+ }}</span>
2084
3058
  } @else {
2085
3059
  <select
2086
- [id]="field.id"
2087
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
3060
+ [id]="domId()"
3061
+ class="ngx-field__input"
3062
+ [attr.data-testid]="'field-' + field.id + '-input'"
2088
3063
  [formControl]="$any(control)"
2089
3064
  [compareWith]="compareFn"
2090
3065
  [attr.disabled]="field.disabled ? true : null"
2091
3066
  [attr.aria-invalid]="control.invalid && control.touched"
2092
- [attr.aria-describedby]="errorMessage ? field.id + '-error' : null"
3067
+ [attr.aria-describedby]="errorMessage ? domId('-error') : null"
2093
3068
  >
2094
- <option [value]="''">{{ placeholder || 'Select...' }}</option>
3069
+ <option [value]="''">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
2095
3070
  @for (option of options(); track getOptLabel(option)) {
2096
3071
  @if (isObjectVal(option)) {
2097
3072
  <option [ngValue]="getOptStoredVal(option)">{{ getOptLabel(option) }}</option>
@@ -2101,7 +3076,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2101
3076
  }
2102
3077
  </select>
2103
3078
  @if (errorMessage) {
2104
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" [id]="field.id + '-error'" role="alert">{{ errorMessage }}</span>
3079
+ <span
3080
+ class="ngx-field__error"
3081
+ [attr.data-testid]="'field-' + field.id + '-error'"
3082
+ [id]="domId('-error')"
3083
+ role="alert"
3084
+ >{{ errorMessage }}</span
3085
+ >
2105
3086
  }
2106
3087
  }
2107
3088
  </div>
@@ -2121,6 +3102,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2121
3102
 
2122
3103
  /** Radio field: a group of radio buttons built from field.options. */
2123
3104
  class RadioFieldComponent {
3105
+ /**
3106
+ * Unique to this component instance: an `array` renders the same field once per row, and a
3107
+ * DOM id may not repeat. See `field-dom-id.ts`.
3108
+ */
3109
+ instanceId = nextFieldInstanceId();
3110
+ domId(suffix = '') {
3111
+ return fieldDomId(this.field, this.instanceId, suffix);
3112
+ }
3113
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
3114
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3115
+ messages = inject(ValidationMessagesService);
2124
3116
  lookups = inject(LookupRegistryService);
2125
3117
  _field;
2126
3118
  _language = 'en';
@@ -2156,12 +3148,19 @@ class RadioFieldComponent {
2156
3148
  * into an id attribute.
2157
3149
  */
2158
3150
  getRadioId(option) {
2159
- // `??` cannot fire here: `resolveOptionValue` returns '' for an empty option, not null,
2160
- // so every valueless option produced the same id — and duplicate ids break the `for`
2161
- // that ties each label to its input.
3151
+ return this.domId(`-${this.optionSlug(option)}`);
3152
+ }
3153
+ /**
3154
+ * The option's part of an id or a test hook.
3155
+ *
3156
+ * `??` cannot fire here: `resolveOptionValue` returns '' for an empty option, not null, so
3157
+ * every valueless option produced the same slug — and two radios sharing an id break the
3158
+ * `for` that ties each label to its input.
3159
+ */
3160
+ optionSlug(option) {
2162
3161
  const raw = String(this.getOptVal(option) ?? '').trim();
2163
3162
  const text = raw || 'opt';
2164
- return `${this.field.id}-${text.trim().replace(/\s+/g, '_').toLowerCase()}`;
3163
+ return text.trim().replace(/\s+/g, '_').toLowerCase();
2165
3164
  }
2166
3165
  getOptVal(option) {
2167
3166
  return resolveOptionValue(option, this.language);
@@ -2184,14 +3183,40 @@ class RadioFieldComponent {
2184
3183
  return cached;
2185
3184
  return typeof value === 'object' ? resolveLabel(value, this.language) : String(value ?? '—');
2186
3185
  }
3186
+ /**
3187
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
3188
+ * validationMessages })` reaches this field. It used to render a fixed
3189
+ * "This field has an error", which made a documented, configurable feature work on three
3190
+ * of fifteen field types.
3191
+ */
3192
+ get errorMessage() {
3193
+ if (!this.control?.errors || !this.control.touched)
3194
+ return '';
3195
+ return this.messages.resolve(this.control.errors, this.language, [
3196
+ 'required',
3197
+ 'email',
3198
+ 'min',
3199
+ 'max',
3200
+ 'minlength',
3201
+ 'maxlength',
3202
+ 'pattern',
3203
+ ]);
3204
+ }
2187
3205
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2188
3206
  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: `
2189
- <div class="ngx-field ngx-field--radio"
2190
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3207
+ <div
3208
+ class="ngx-field ngx-field--radio"
3209
+ [attr.data-testid]="'field-' + field.id"
3210
+ [attr.data-field-type]="field.type"
3211
+ [class.ngx-field--readonly]="readonly"
3212
+ [class.ngx-field--masked]="masked"
3213
+ >
2191
3214
  <fieldset class="ngx-field__fieldset">
2192
3215
  <legend class="ngx-field__label">{{ label }}</legend>
2193
3216
  @if (masked) {
2194
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3217
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3218
+ maskedText
3219
+ }}</span>
2195
3220
  } @else if (readonly) {
2196
3221
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getSelectedLabel() }}</span>
2197
3222
  } @else {
@@ -2200,6 +3225,7 @@ class RadioFieldComponent {
2200
3225
  <label class="ngx-field__radio-option" [attr.for]="getRadioId(option)">
2201
3226
  <input
2202
3227
  [id]="getRadioId(option)"
3228
+ [attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
2203
3229
  type="radio"
2204
3230
  class="ngx-field__radio-input"
2205
3231
  [formControl]="$any(control)"
@@ -2210,8 +3236,8 @@ class RadioFieldComponent {
2210
3236
  </label>
2211
3237
  }
2212
3238
  </div>
2213
- @if (control.invalid && control.touched) {
2214
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
3239
+ @if (errorMessage) {
3240
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2215
3241
  }
2216
3242
  }
2217
3243
  </fieldset>
@@ -2226,12 +3252,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2226
3252
  standalone: true,
2227
3253
  imports: [ReactiveFormsModule],
2228
3254
  template: `
2229
- <div class="ngx-field ngx-field--radio"
2230
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3255
+ <div
3256
+ class="ngx-field ngx-field--radio"
3257
+ [attr.data-testid]="'field-' + field.id"
3258
+ [attr.data-field-type]="field.type"
3259
+ [class.ngx-field--readonly]="readonly"
3260
+ [class.ngx-field--masked]="masked"
3261
+ >
2231
3262
  <fieldset class="ngx-field__fieldset">
2232
3263
  <legend class="ngx-field__label">{{ label }}</legend>
2233
3264
  @if (masked) {
2234
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3265
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3266
+ maskedText
3267
+ }}</span>
2235
3268
  } @else if (readonly) {
2236
3269
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getSelectedLabel() }}</span>
2237
3270
  } @else {
@@ -2240,6 +3273,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2240
3273
  <label class="ngx-field__radio-option" [attr.for]="getRadioId(option)">
2241
3274
  <input
2242
3275
  [id]="getRadioId(option)"
3276
+ [attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
2243
3277
  type="radio"
2244
3278
  class="ngx-field__radio-input"
2245
3279
  [formControl]="$any(control)"
@@ -2250,8 +3284,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2250
3284
  </label>
2251
3285
  }
2252
3286
  </div>
2253
- @if (control.invalid && control.touched) {
2254
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
3287
+ @if (errorMessage) {
3288
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2255
3289
  }
2256
3290
  }
2257
3291
  </fieldset>
@@ -2271,6 +3305,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2271
3305
  }] } });
2272
3306
 
2273
3307
  class MultiSelectFieldComponent {
3308
+ /**
3309
+ * Unique to this component instance: an `array` renders the same field once per row, and a
3310
+ * DOM id may not repeat. See `field-dom-id.ts`.
3311
+ */
3312
+ instanceId = nextFieldInstanceId();
3313
+ domId(suffix = '') {
3314
+ return fieldDomId(this.field, this.instanceId, suffix);
3315
+ }
3316
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
3317
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3318
+ messages = inject(ValidationMessagesService);
2274
3319
  lookups = inject(LookupRegistryService);
2275
3320
  _field;
2276
3321
  _language = 'en';
@@ -2324,19 +3369,48 @@ class MultiSelectFieldComponent {
2324
3369
  })
2325
3370
  .join(', ');
2326
3371
  }
3372
+ /**
3373
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
3374
+ * validationMessages })` reaches this field. It used to render a fixed
3375
+ * "This field has an error", which made a documented, configurable feature work on three
3376
+ * of fifteen field types.
3377
+ */
3378
+ get errorMessage() {
3379
+ if (!this.control?.errors || !this.control.touched)
3380
+ return '';
3381
+ return this.messages.resolve(this.control.errors, this.language, [
3382
+ 'required',
3383
+ 'email',
3384
+ 'min',
3385
+ 'max',
3386
+ 'minlength',
3387
+ 'maxlength',
3388
+ 'pattern',
3389
+ ]);
3390
+ }
2327
3391
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiSelectFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2328
3392
  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: `
2329
- <div class="ngx-field ngx-field--multiSelect"
2330
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2331
- <label class="ngx-field__label">{{ label }}</label>
3393
+ <div
3394
+ class="ngx-field ngx-field--multiSelect"
3395
+ [attr.data-testid]="'field-' + field.id"
3396
+ [attr.data-field-type]="field.type"
3397
+ [class.ngx-field--readonly]="readonly"
3398
+ [class.ngx-field--masked]="masked"
3399
+ >
3400
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2332
3401
  @if (masked) {
2333
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3402
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3403
+ maskedText
3404
+ }}</span>
2334
3405
  } @else if (readonly) {
2335
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabels(control.value) }}</span>
3406
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
3407
+ getLabels(control.value)
3408
+ }}</span>
2336
3409
  } @else {
2337
3410
  <select
2338
- [id]="field.id"
2339
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
3411
+ [id]="domId()"
3412
+ class="ngx-field__input"
3413
+ [attr.data-testid]="'field-' + field.id + '-input'"
2340
3414
  [formControl]="$any(control)"
2341
3415
  [compareWith]="compareFn"
2342
3416
  [attr.disabled]="field.disabled ? true : null"
@@ -2351,8 +3425,8 @@ class MultiSelectFieldComponent {
2351
3425
  }
2352
3426
  }
2353
3427
  </select>
2354
- @if (control.invalid && control.touched) {
2355
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
3428
+ @if (errorMessage) {
3429
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2356
3430
  }
2357
3431
  }
2358
3432
  </div>
@@ -2366,17 +3440,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2366
3440
  standalone: true,
2367
3441
  imports: [ReactiveFormsModule],
2368
3442
  template: `
2369
- <div class="ngx-field ngx-field--multiSelect"
2370
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2371
- <label class="ngx-field__label">{{ label }}</label>
3443
+ <div
3444
+ class="ngx-field ngx-field--multiSelect"
3445
+ [attr.data-testid]="'field-' + field.id"
3446
+ [attr.data-field-type]="field.type"
3447
+ [class.ngx-field--readonly]="readonly"
3448
+ [class.ngx-field--masked]="masked"
3449
+ >
3450
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2372
3451
  @if (masked) {
2373
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3452
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3453
+ maskedText
3454
+ }}</span>
2374
3455
  } @else if (readonly) {
2375
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabels(control.value) }}</span>
3456
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
3457
+ getLabels(control.value)
3458
+ }}</span>
2376
3459
  } @else {
2377
3460
  <select
2378
- [id]="field.id"
2379
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
3461
+ [id]="domId()"
3462
+ class="ngx-field__input"
3463
+ [attr.data-testid]="'field-' + field.id + '-input'"
2380
3464
  [formControl]="$any(control)"
2381
3465
  [compareWith]="compareFn"
2382
3466
  [attr.disabled]="field.disabled ? true : null"
@@ -2391,8 +3475,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2391
3475
  }
2392
3476
  }
2393
3477
  </select>
2394
- @if (control.invalid && control.touched) {
2395
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
3478
+ @if (errorMessage) {
3479
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2396
3480
  }
2397
3481
  }
2398
3482
  </div>
@@ -2758,6 +3842,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2758
3842
  * `EntityRefSelectionService` so the owning form can run `autoPatch`.
2759
3843
  */
2760
3844
  class EntityRefFieldComponent {
3845
+ /**
3846
+ * Unique to this component instance: an `array` renders the same field once per row, and a
3847
+ * DOM id may not repeat. See `field-dom-id.ts`.
3848
+ */
3849
+ instanceId = nextFieldInstanceId();
3850
+ domId(suffix = '') {
3851
+ return fieldDomId(this.field, this.instanceId, suffix);
3852
+ }
3853
+ /** Library chrome, overridable via UI_TEXT. */
3854
+ ui = inject(UiTextService);
3855
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
3856
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3857
+ messages = inject(ValidationMessagesService);
2761
3858
  field;
2762
3859
  control;
2763
3860
  language = 'en';
@@ -2822,7 +3919,7 @@ class EntityRefFieldComponent {
2822
3919
  }
2823
3920
  getLabel(value) {
2824
3921
  const option = this.options().find(o => String(o.value) === String(value));
2825
- return option?.label ?? (value ?? '—');
3922
+ return option?.label ?? value ?? '—';
2826
3923
  }
2827
3924
  parentValue() {
2828
3925
  const parentId = this.parentFieldId;
@@ -2838,9 +3935,7 @@ class EntityRefFieldComponent {
2838
3935
  const parentControl = this.control?.parent?.get(parentId);
2839
3936
  if (!parentControl)
2840
3937
  return;
2841
- parentControl.valueChanges
2842
- .pipe(takeUntilDestroyed(this.destroyRef))
2843
- .subscribe(() => {
3938
+ parentControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
2844
3939
  if (this.control.value) {
2845
3940
  this.control.setValue('', { emitEvent: false });
2846
3941
  this.selectionBus.emit(this.field.id, null);
@@ -2848,36 +3943,69 @@ class EntityRefFieldComponent {
2848
3943
  void this.reload();
2849
3944
  });
2850
3945
  }
3946
+ /**
3947
+ * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
3948
+ * validationMessages })` reaches this field. It used to render a fixed
3949
+ * "This field has an error", which made a documented, configurable feature work on three
3950
+ * of fifteen field types.
3951
+ */
3952
+ get errorMessage() {
3953
+ if (!this.control?.errors || !this.control.touched)
3954
+ return '';
3955
+ return this.messages.resolve(this.control.errors, this.language, [
3956
+ 'required',
3957
+ 'email',
3958
+ 'min',
3959
+ 'max',
3960
+ 'minlength',
3961
+ 'maxlength',
3962
+ 'pattern',
3963
+ ]);
3964
+ }
2851
3965
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityRefFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2852
3966
  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: `
2853
- <div class="ngx-field ngx-field--entity-ref"
2854
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2855
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
3967
+ <div
3968
+ class="ngx-field ngx-field--entity-ref"
3969
+ [attr.data-testid]="'field-' + field.id"
3970
+ [attr.data-field-type]="field.type"
3971
+ [class.ngx-field--readonly]="readonly"
3972
+ [class.ngx-field--masked]="masked"
3973
+ >
3974
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2856
3975
  @if (masked) {
2857
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
3976
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
3977
+ maskedText
3978
+ }}</span>
2858
3979
  } @else if (readonly) {
2859
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabel(control.value) }}</span>
3980
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
3981
+ getLabel(control.value)
3982
+ }}</span>
2860
3983
  } @else {
2861
3984
  @if (loading()) {
2862
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">Loading…</span>
3985
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">{{
3986
+ ui.text('loading', language)
3987
+ }}</span>
2863
3988
  } @else {
2864
3989
  <select
2865
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2866
- [id]="'field-' + field.id"
3990
+ class="ngx-field__input"
3991
+ [attr.data-testid]="'field-' + field.id + '-input'"
3992
+ [id]="domId()"
2867
3993
  [formControl]="$any(control)"
2868
3994
  [attr.disabled]="field.disabled ? true : null"
2869
3995
  >
2870
- <option value="">{{ placeholder || 'Select...' }}</option>
3996
+ <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
2871
3997
  @for (option of options(); track option.value) {
2872
3998
  <option [value]="option.value">{{ option.label }}</option>
2873
3999
  }
2874
4000
  </select>
2875
4001
  @if (awaitingParent()) {
2876
- <span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">Select {{ parentFieldId }} first.</span>
4002
+ <span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">{{
4003
+ ui.text('selectParentFirst', language, { field: parentFieldId ?? '' })
4004
+ }}</span>
2877
4005
  }
2878
4006
  }
2879
- @if (control.invalid && control.touched) {
2880
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
4007
+ @if (errorMessage) {
4008
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2881
4009
  }
2882
4010
  }
2883
4011
  </div>
@@ -2891,34 +4019,48 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2891
4019
  standalone: true,
2892
4020
  imports: [ReactiveFormsModule],
2893
4021
  template: `
2894
- <div class="ngx-field ngx-field--entity-ref"
2895
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2896
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
4022
+ <div
4023
+ class="ngx-field ngx-field--entity-ref"
4024
+ [attr.data-testid]="'field-' + field.id"
4025
+ [attr.data-field-type]="field.type"
4026
+ [class.ngx-field--readonly]="readonly"
4027
+ [class.ngx-field--masked]="masked"
4028
+ >
4029
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
2897
4030
  @if (masked) {
2898
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4031
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4032
+ maskedText
4033
+ }}</span>
2899
4034
  } @else if (readonly) {
2900
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ getLabel(control.value) }}</span>
4035
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{
4036
+ getLabel(control.value)
4037
+ }}</span>
2901
4038
  } @else {
2902
4039
  @if (loading()) {
2903
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">Loading…</span>
4040
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-loading'" role="status">{{
4041
+ ui.text('loading', language)
4042
+ }}</span>
2904
4043
  } @else {
2905
4044
  <select
2906
- class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
2907
- [id]="'field-' + field.id"
4045
+ class="ngx-field__input"
4046
+ [attr.data-testid]="'field-' + field.id + '-input'"
4047
+ [id]="domId()"
2908
4048
  [formControl]="$any(control)"
2909
4049
  [attr.disabled]="field.disabled ? true : null"
2910
4050
  >
2911
- <option value="">{{ placeholder || 'Select...' }}</option>
4051
+ <option value="">{{ placeholder || ui.text('selectPlaceholder', language) }}</option>
2912
4052
  @for (option of options(); track option.value) {
2913
4053
  <option [value]="option.value">{{ option.label }}</option>
2914
4054
  }
2915
4055
  </select>
2916
4056
  @if (awaitingParent()) {
2917
- <span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">Select {{ parentFieldId }} first.</span>
4057
+ <span class="ngx-field__hint" [attr.data-testid]="'field-' + field.id + '-hint'">{{
4058
+ ui.text('selectParentFirst', language, { field: parentFieldId ?? '' })
4059
+ }}</span>
2918
4060
  }
2919
4061
  }
2920
- @if (control.invalid && control.touched) {
2921
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
4062
+ @if (errorMessage) {
4063
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ errorMessage }}</span>
2922
4064
  }
2923
4065
  }
2924
4066
  </div>
@@ -3179,6 +4321,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3179
4321
 
3180
4322
  /** Renders a nested fieldset container for a group field's children. */
3181
4323
  class GroupFieldComponent {
4324
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
4325
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3182
4326
  field;
3183
4327
  control;
3184
4328
  config;
@@ -3199,7 +4343,7 @@ class GroupFieldComponent {
3199
4343
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3200
4344
  <legend class="ngx-field__legend">{{ label }}</legend>
3201
4345
  @if (masked) {
3202
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4346
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
3203
4347
  } @else {
3204
4348
  <div class="ngx-field__group-children">
3205
4349
  @for (child of field.children ?? []; track child.id) {
@@ -3226,7 +4370,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3226
4370
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3227
4371
  <legend class="ngx-field__legend">{{ label }}</legend>
3228
4372
  @if (masked) {
3229
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4373
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{ maskedText }}</span>
3230
4374
  } @else {
3231
4375
  <div class="ngx-field__group-children">
3232
4376
  @for (child of field.children ?? []; track child.id) {
@@ -3358,6 +4502,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3358
4502
 
3359
4503
  /** Renders repeating rows for a FormArray, with Add Item and Remove. */
3360
4504
  class ArrayFieldComponent {
4505
+ /** Library chrome, overridable via UI_TEXT. */
4506
+ ui = inject(UiTextService);
4507
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
4508
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3361
4509
  /**
3362
4510
  * addItem/removeItem are public and may be called from outside this component's template,
3363
4511
  * which under OnPush would not re-render. The host also watches the control, but this
@@ -3398,30 +4546,45 @@ class ArrayFieldComponent {
3398
4546
  }
3399
4547
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3400
4548
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ArrayFieldComponent, isStandalone: true, selector: "ngx-array-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", masked: "masked", userRoles: "userRoles" }, ngImport: i0, template: `
3401
- <div class="ngx-field ngx-field--array"
3402
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4549
+ <div
4550
+ class="ngx-field ngx-field--array"
4551
+ [attr.data-testid]="'field-' + field.id"
4552
+ [attr.data-field-type]="field.type"
4553
+ [class.ngx-field--readonly]="readonly"
4554
+ [class.ngx-field--masked]="masked"
4555
+ >
3403
4556
  <div class="ngx-field__array-header">
3404
4557
  <label class="ngx-field__label">{{ label }}</label>
3405
4558
  @if (!readonly && !masked) {
3406
- <button type="button" class="ngx-field__array-add-btn"
3407
- [attr.data-testid]="'field-' + field.id + '-add'" (click)="addItem()">
3408
- + Add Item
4559
+ <button
4560
+ type="button"
4561
+ class="ngx-field__array-add-btn"
4562
+ [attr.data-testid]="'field-' + field.id + '-add'"
4563
+ (click)="addItem()"
4564
+ >
4565
+ {{ ui.text('addItem', language) }}
3409
4566
  </button>
3410
4567
  }
3411
4568
  </div>
3412
4569
 
3413
4570
  @if (masked) {
3414
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4571
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4572
+ maskedText
4573
+ }}</span>
3415
4574
  } @else {
3416
4575
  <div class="ngx-field__array-list">
3417
4576
  @for (itemGroup of formArray.controls; track idx; let idx = $index) {
3418
4577
  <div class="ngx-field__array-item" [attr.data-testid]="'field-' + field.id + '-row'">
3419
4578
  <div class="ngx-field__array-item-header">
3420
- <span class="ngx-field__array-item-title">Item #{{ idx + 1 }}</span>
4579
+ <span class="ngx-field__array-item-title">{{ ui.text('itemNumber', language, { number: idx + 1 }) }}</span>
3421
4580
  @if (!readonly) {
3422
- <button type="button" class="ngx-field__array-remove-btn"
3423
- [attr.data-testid]="'field-' + field.id + '-remove-' + idx" (click)="removeItem(idx)">
3424
- Remove
4581
+ <button
4582
+ type="button"
4583
+ class="ngx-field__array-remove-btn"
4584
+ [attr.data-testid]="'field-' + field.id + '-remove-' + idx"
4585
+ (click)="removeItem(idx)"
4586
+ >
4587
+ {{ ui.text('remove', language) }}
3425
4588
  </button>
3426
4589
  }
3427
4590
  </div>
@@ -3442,7 +4605,9 @@ class ArrayFieldComponent {
3442
4605
  </div>
3443
4606
  }
3444
4607
  @if (formArray.controls.length === 0) {
3445
- <div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">No items added yet.</div>
4608
+ <div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
4609
+ {{ ui.text('noItems', language) }}
4610
+ </div>
3446
4611
  }
3447
4612
  </div>
3448
4613
  }
@@ -3452,30 +4617,45 @@ class ArrayFieldComponent {
3452
4617
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ArrayFieldComponent, decorators: [{
3453
4618
  type: Component,
3454
4619
  args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
3455
- <div class="ngx-field ngx-field--array"
3456
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
4620
+ <div
4621
+ class="ngx-field ngx-field--array"
4622
+ [attr.data-testid]="'field-' + field.id"
4623
+ [attr.data-field-type]="field.type"
4624
+ [class.ngx-field--readonly]="readonly"
4625
+ [class.ngx-field--masked]="masked"
4626
+ >
3457
4627
  <div class="ngx-field__array-header">
3458
4628
  <label class="ngx-field__label">{{ label }}</label>
3459
4629
  @if (!readonly && !masked) {
3460
- <button type="button" class="ngx-field__array-add-btn"
3461
- [attr.data-testid]="'field-' + field.id + '-add'" (click)="addItem()">
3462
- + Add Item
4630
+ <button
4631
+ type="button"
4632
+ class="ngx-field__array-add-btn"
4633
+ [attr.data-testid]="'field-' + field.id + '-add'"
4634
+ (click)="addItem()"
4635
+ >
4636
+ {{ ui.text('addItem', language) }}
3463
4637
  </button>
3464
4638
  }
3465
4639
  </div>
3466
4640
 
3467
4641
  @if (masked) {
3468
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4642
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4643
+ maskedText
4644
+ }}</span>
3469
4645
  } @else {
3470
4646
  <div class="ngx-field__array-list">
3471
4647
  @for (itemGroup of formArray.controls; track idx; let idx = $index) {
3472
4648
  <div class="ngx-field__array-item" [attr.data-testid]="'field-' + field.id + '-row'">
3473
4649
  <div class="ngx-field__array-item-header">
3474
- <span class="ngx-field__array-item-title">Item #{{ idx + 1 }}</span>
4650
+ <span class="ngx-field__array-item-title">{{ ui.text('itemNumber', language, { number: idx + 1 }) }}</span>
3475
4651
  @if (!readonly) {
3476
- <button type="button" class="ngx-field__array-remove-btn"
3477
- [attr.data-testid]="'field-' + field.id + '-remove-' + idx" (click)="removeItem(idx)">
3478
- Remove
4652
+ <button
4653
+ type="button"
4654
+ class="ngx-field__array-remove-btn"
4655
+ [attr.data-testid]="'field-' + field.id + '-remove-' + idx"
4656
+ (click)="removeItem(idx)"
4657
+ >
4658
+ {{ ui.text('remove', language) }}
3479
4659
  </button>
3480
4660
  }
3481
4661
  </div>
@@ -3496,7 +4676,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3496
4676
  </div>
3497
4677
  }
3498
4678
  @if (formArray.controls.length === 0) {
3499
- <div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">No items added yet.</div>
4679
+ <div class="ngx-field__array-empty" [attr.data-testid]="'field-' + field.id + '-empty'">
4680
+ {{ ui.text('noItems', language) }}
4681
+ </div>
3500
4682
  }
3501
4683
  </div>
3502
4684
  }
@@ -3611,6 +4793,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3611
4793
  * it is stored as `{ file }` and previewed from an object URL (revoked when replaced/destroyed).
3612
4794
  */
3613
4795
  class ImageFieldComponent {
4796
+ /**
4797
+ * Unique to this component instance: an `array` renders the same field once per row, and a
4798
+ * DOM id may not repeat. See `field-dom-id.ts`.
4799
+ */
4800
+ instanceId = nextFieldInstanceId();
4801
+ domId(suffix = '') {
4802
+ return fieldDomId(this.field, this.instanceId, suffix);
4803
+ }
4804
+ /** Library chrome, overridable via UI_TEXT. */
4805
+ ui = inject(UiTextService);
4806
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
4807
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
4808
+ messages = inject(ValidationMessagesService);
3614
4809
  field;
3615
4810
  control;
3616
4811
  language = 'en';
@@ -3657,13 +4852,26 @@ class ImageFieldComponent {
3657
4852
  ngOnDestroy() {
3658
4853
  this.uploads.revokePreviewUrl(this.previewCache);
3659
4854
  }
4855
+ /** Validation, as distinct from the upload failures this field already reported. */
4856
+ get errorMessage() {
4857
+ if (!this.control?.errors || !this.control.touched)
4858
+ return '';
4859
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
4860
+ }
3660
4861
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImageFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3661
4862
  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: `
3662
- <div class="ngx-field ngx-field--image"
3663
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3664
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
4863
+ <div
4864
+ class="ngx-field ngx-field--image"
4865
+ [attr.data-testid]="'field-' + field.id"
4866
+ [attr.data-field-type]="field.type"
4867
+ [class.ngx-field--readonly]="readonly"
4868
+ [class.ngx-field--masked]="masked"
4869
+ >
4870
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
3665
4871
  @if (masked) {
3666
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4872
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4873
+ maskedText
4874
+ }}</span>
3667
4875
  } @else {
3668
4876
  <div class="ngx-field__image-wrap">
3669
4877
  @if (previewUrl()) {
@@ -3671,20 +4879,20 @@ class ImageFieldComponent {
3671
4879
  } @else {
3672
4880
  <div class="ngx-field__image-placeholder">
3673
4881
  <span>📷</span>
3674
- <span>No image</span>
4882
+ <span>{{ ui.text('noImage', language) }}</span>
3675
4883
  </div>
3676
4884
  }
3677
4885
  @if (!readonly) {
3678
4886
  <div class="ngx-field__image-actions">
3679
4887
  <label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
3680
4888
  @if (uploading()) {
3681
- <span>Uploading…</span>
4889
+ <span>{{ ui.text('uploading', language) }}</span>
3682
4890
  } @else {
3683
- <span>{{ previewUrl() ? 'Change' : 'Upload' }}</span>
4891
+ <span>{{ ui.text(previewUrl() ? 'changeImage' : 'uploadImage', language) }}</span>
3684
4892
  <input
3685
4893
  type="file"
3686
4894
  class="ngx-field__file-input"
3687
- [id]="'field-' + field.id"
4895
+ [id]="domId()"
3688
4896
  accept="image/*"
3689
4897
  [disabled]="field.disabled || uploading()"
3690
4898
  (change)="onFileSelect($any($event.target).files[0])"
@@ -3692,13 +4900,20 @@ class ImageFieldComponent {
3692
4900
  }
3693
4901
  </label>
3694
4902
  @if (previewUrl()) {
3695
- <button type="button" class="ngx-field__remove-btn" (click)="remove()">Remove</button>
4903
+ <button type="button" class="ngx-field__remove-btn" (click)="remove()">
4904
+ {{ ui.text('remove', language) }}
4905
+ </button>
3696
4906
  }
3697
4907
  </div>
3698
4908
  }
3699
4909
  </div>
3700
- @if (uploadError()) {
3701
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ uploadError() }}</span>
4910
+ <!-- An upload failure and an unmet validator are different problems, and this only
4911
+ ever showed the first. A required file left unchosen said nothing, and a
4912
+ configured required message had no element to appear in. -->
4913
+ @if (uploadError() || errorMessage) {
4914
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
4915
+ uploadError() || errorMessage
4916
+ }}</span>
3702
4917
  }
3703
4918
  }
3704
4919
  </div>
@@ -3712,11 +4927,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3712
4927
  standalone: true,
3713
4928
  imports: [],
3714
4929
  template: `
3715
- <div class="ngx-field ngx-field--image"
3716
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3717
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
4930
+ <div
4931
+ class="ngx-field ngx-field--image"
4932
+ [attr.data-testid]="'field-' + field.id"
4933
+ [attr.data-field-type]="field.type"
4934
+ [class.ngx-field--readonly]="readonly"
4935
+ [class.ngx-field--masked]="masked"
4936
+ >
4937
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
3718
4938
  @if (masked) {
3719
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
4939
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
4940
+ maskedText
4941
+ }}</span>
3720
4942
  } @else {
3721
4943
  <div class="ngx-field__image-wrap">
3722
4944
  @if (previewUrl()) {
@@ -3724,20 +4946,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3724
4946
  } @else {
3725
4947
  <div class="ngx-field__image-placeholder">
3726
4948
  <span>📷</span>
3727
- <span>No image</span>
4949
+ <span>{{ ui.text('noImage', language) }}</span>
3728
4950
  </div>
3729
4951
  }
3730
4952
  @if (!readonly) {
3731
4953
  <div class="ngx-field__image-actions">
3732
4954
  <label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
3733
4955
  @if (uploading()) {
3734
- <span>Uploading…</span>
4956
+ <span>{{ ui.text('uploading', language) }}</span>
3735
4957
  } @else {
3736
- <span>{{ previewUrl() ? 'Change' : 'Upload' }}</span>
4958
+ <span>{{ ui.text(previewUrl() ? 'changeImage' : 'uploadImage', language) }}</span>
3737
4959
  <input
3738
4960
  type="file"
3739
4961
  class="ngx-field__file-input"
3740
- [id]="'field-' + field.id"
4962
+ [id]="domId()"
3741
4963
  accept="image/*"
3742
4964
  [disabled]="field.disabled || uploading()"
3743
4965
  (change)="onFileSelect($any($event.target).files[0])"
@@ -3745,13 +4967,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3745
4967
  }
3746
4968
  </label>
3747
4969
  @if (previewUrl()) {
3748
- <button type="button" class="ngx-field__remove-btn" (click)="remove()">Remove</button>
4970
+ <button type="button" class="ngx-field__remove-btn" (click)="remove()">
4971
+ {{ ui.text('remove', language) }}
4972
+ </button>
3749
4973
  }
3750
4974
  </div>
3751
4975
  }
3752
4976
  </div>
3753
- @if (uploadError()) {
3754
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ uploadError() }}</span>
4977
+ <!-- An upload failure and an unmet validator are different problems, and this only
4978
+ ever showed the first. A required file left unchosen said nothing, and a
4979
+ configured required message had no element to appear in. -->
4980
+ @if (uploadError() || errorMessage) {
4981
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
4982
+ uploadError() || errorMessage
4983
+ }}</span>
3755
4984
  }
3756
4985
  }
3757
4986
  </div>
@@ -3774,6 +5003,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3774
5003
  * Shares the `FileRef` / `UPLOAD_HANDLER` contract with image-field via FileUploadService.
3775
5004
  */
3776
5005
  class FileFieldComponent {
5006
+ /**
5007
+ * Unique to this component instance: an `array` renders the same field once per row, and a
5008
+ * DOM id may not repeat. See `field-dom-id.ts`.
5009
+ */
5010
+ instanceId = nextFieldInstanceId();
5011
+ domId(suffix = '') {
5012
+ return fieldDomId(this.field, this.instanceId, suffix);
5013
+ }
5014
+ /** Library chrome, overridable via UI_TEXT. */
5015
+ ui = inject(UiTextService);
5016
+ /** Overridable via MASKED_PLACEHOLDER; the default is the historic literal. */
5017
+ maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
5018
+ messages = inject(ValidationMessagesService);
3777
5019
  field;
3778
5020
  control;
3779
5021
  language = 'en';
@@ -3814,17 +5056,30 @@ class FileFieldComponent {
3814
5056
  this.control.setValue(null);
3815
5057
  this.control.markAsTouched();
3816
5058
  }
5059
+ /** Validation, as distinct from the upload failures this field already reported. */
5060
+ get errorMessage() {
5061
+ if (!this.control?.errors || !this.control.touched)
5062
+ return '';
5063
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'pattern']);
5064
+ }
3817
5065
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FileFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
3818
5066
  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: `
3819
- <div class="ngx-field ngx-field--file"
3820
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3821
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
5067
+ <div
5068
+ class="ngx-field ngx-field--file"
5069
+ [attr.data-testid]="'field-' + field.id"
5070
+ [attr.data-field-type]="field.type"
5071
+ [class.ngx-field--readonly]="readonly"
5072
+ [class.ngx-field--masked]="masked"
5073
+ >
5074
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
3822
5075
  @if (masked) {
3823
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
5076
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5077
+ maskedText
5078
+ }}</span>
3824
5079
  } @else if (readonly) {
3825
5080
  @if (fileUrl()) {
3826
5081
  <a class="ngx-field__file-link" [href]="fileUrl()!" target="_blank" rel="noopener">
3827
- 📎 {{ fileName() || 'Download file' }}
5082
+ 📎 {{ fileName() || ui.text('downloadFile', language) }}
3828
5083
  </a>
3829
5084
  } @else {
3830
5085
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">—</span>
@@ -3834,26 +5089,38 @@ class FileFieldComponent {
3834
5089
  @if (fileName()) {
3835
5090
  <div class="ngx-field__file-selected">
3836
5091
  <span class="ngx-field__file-name">📎 {{ fileName() }}</span>
3837
- <button type="button" class="ngx-field__remove-btn" (click)="remove()" aria-label="Remove file">✕</button>
5092
+ <button
5093
+ type="button"
5094
+ class="ngx-field__remove-btn"
5095
+ (click)="remove()"
5096
+ [attr.aria-label]="ui.text('removeFile', language)"
5097
+ >
5098
+ ✕
5099
+ </button>
3838
5100
  </div>
3839
5101
  }
3840
5102
  <label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
3841
5103
  @if (uploading()) {
3842
- <span>Uploading…</span>
5104
+ <span>{{ ui.text('uploading', language) }}</span>
3843
5105
  } @else {
3844
- <span>{{ fileName() ? 'Replace file' : 'Choose file' }}</span>
5106
+ <span>{{ fileName() ? ui.text('replaceFile', language) : ui.text('chooseFile', language) }}</span>
3845
5107
  <input
3846
5108
  type="file"
3847
5109
  class="ngx-field__file-input"
3848
- [id]="'field-' + field.id"
5110
+ [id]="domId()"
3849
5111
  [disabled]="field.disabled || uploading()"
3850
5112
  (change)="onFileSelect($any($event.target).files[0])"
3851
5113
  />
3852
5114
  }
3853
5115
  </label>
3854
5116
  </div>
3855
- @if (uploadError()) {
3856
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ uploadError() }}</span>
5117
+ <!-- An upload failure and an unmet validator are different problems, and this only
5118
+ ever showed the first. A required file left unchosen said nothing, and a
5119
+ configured required message had no element to appear in. -->
5120
+ @if (uploadError() || errorMessage) {
5121
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5122
+ uploadError() || errorMessage
5123
+ }}</span>
3857
5124
  }
3858
5125
  }
3859
5126
  </div>
@@ -3867,15 +5134,22 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3867
5134
  standalone: true,
3868
5135
  imports: [],
3869
5136
  template: `
3870
- <div class="ngx-field ngx-field--file"
3871
- [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
3872
- <label class="ngx-field__label" [attr.for]="'field-' + field.id">{{ label }}</label>
5137
+ <div
5138
+ class="ngx-field ngx-field--file"
5139
+ [attr.data-testid]="'field-' + field.id"
5140
+ [attr.data-field-type]="field.type"
5141
+ [class.ngx-field--readonly]="readonly"
5142
+ [class.ngx-field--masked]="masked"
5143
+ >
5144
+ <label class="ngx-field__label" [attr.for]="domId()">{{ label }}</label>
3873
5145
  @if (masked) {
3874
- <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
5146
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">{{
5147
+ maskedText
5148
+ }}</span>
3875
5149
  } @else if (readonly) {
3876
5150
  @if (fileUrl()) {
3877
5151
  <a class="ngx-field__file-link" [href]="fileUrl()!" target="_blank" rel="noopener">
3878
- 📎 {{ fileName() || 'Download file' }}
5152
+ 📎 {{ fileName() || ui.text('downloadFile', language) }}
3879
5153
  </a>
3880
5154
  } @else {
3881
5155
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">—</span>
@@ -3885,26 +5159,38 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3885
5159
  @if (fileName()) {
3886
5160
  <div class="ngx-field__file-selected">
3887
5161
  <span class="ngx-field__file-name">📎 {{ fileName() }}</span>
3888
- <button type="button" class="ngx-field__remove-btn" (click)="remove()" aria-label="Remove file">✕</button>
5162
+ <button
5163
+ type="button"
5164
+ class="ngx-field__remove-btn"
5165
+ (click)="remove()"
5166
+ [attr.aria-label]="ui.text('removeFile', language)"
5167
+ >
5168
+ ✕
5169
+ </button>
3889
5170
  </div>
3890
5171
  }
3891
5172
  <label class="ngx-field__upload-btn" [class.ngx-field__upload-btn--loading]="uploading()">
3892
5173
  @if (uploading()) {
3893
- <span>Uploading…</span>
5174
+ <span>{{ ui.text('uploading', language) }}</span>
3894
5175
  } @else {
3895
- <span>{{ fileName() ? 'Replace file' : 'Choose file' }}</span>
5176
+ <span>{{ fileName() ? ui.text('replaceFile', language) : ui.text('chooseFile', language) }}</span>
3896
5177
  <input
3897
5178
  type="file"
3898
5179
  class="ngx-field__file-input"
3899
- [id]="'field-' + field.id"
5180
+ [id]="domId()"
3900
5181
  [disabled]="field.disabled || uploading()"
3901
5182
  (change)="onFileSelect($any($event.target).files[0])"
3902
5183
  />
3903
5184
  }
3904
5185
  </label>
3905
5186
  </div>
3906
- @if (uploadError()) {
3907
- <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">{{ uploadError() }}</span>
5187
+ <!-- An upload failure and an unmet validator are different problems, and this only
5188
+ ever showed the first. A required file left unchosen said nothing, and a
5189
+ configured required message had no element to appear in. -->
5190
+ @if (uploadError() || errorMessage) {
5191
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'" role="alert">{{
5192
+ uploadError() || errorMessage
5193
+ }}</span>
3908
5194
  }
3909
5195
  }
3910
5196
  </div>
@@ -4093,6 +5379,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4093
5379
  * keyboard shortcuts (Ctrl+S, Esc), and RBAC-gated submission.
4094
5380
  */
4095
5381
  class DynamicFormComponent {
5382
+ /** Library chrome, overridable via UI_TEXT. */
5383
+ ui = inject(UiTextService);
4096
5384
  // ─── Inputs ───────────────────────────────────────────────────────────────
4097
5385
  /**
4098
5386
  * The schema to render.
@@ -4233,18 +5521,46 @@ class DynamicFormComponent {
4233
5521
  const subId = this.activeSubTab();
4234
5522
  return subTabs.find(s => s.id === subId) ?? subTabs[0];
4235
5523
  }
5524
+ /**
5525
+ * The component to mount for the active tab, or `null` if there is nothing to mount.
5526
+ *
5527
+ * A registry entry may name its component with a **selector string** — `COMMON_MODULES`
5528
+ * is a catalogue of names for the builder's picker, and the token's own example showed
5529
+ * one. `ngComponentOutlet` mounts a component *type*, so a string reached it and Angular
5530
+ * threw an assertion the moment somebody opened that tab. Following the documented shape
5531
+ * broke the feature, which is worse than the feature not existing.
5532
+ *
5533
+ * A string now resolves to nothing renderable and says why, once per module.
5534
+ */
4236
5535
  get activeTabModuleComponent() {
4237
5536
  const active = this.activeSubTabConfig ?? this.activeTabConfig;
4238
5537
  if (!active?.moduleName || !this.commonModulesRegistry)
4239
5538
  return null;
4240
5539
  const entry = this.commonModulesRegistry.find(m => m.id === active.moduleName || m.component === active.moduleName);
4241
- return entry ? entry.component : null;
5540
+ if (!entry)
5541
+ return null;
5542
+ if (typeof entry.component === 'string') {
5543
+ this.warnSelectorModule(entry.id, entry.component);
5544
+ return null;
5545
+ }
5546
+ return entry.component;
5547
+ }
5548
+ /** Warned once per module id, because a getter runs on every change-detection pass. */
5549
+ warnedSelectorModules = new Set();
5550
+ warnSelectorModule(id, selector) {
5551
+ if (this.warnedSelectorModules.has(id))
5552
+ return;
5553
+ this.warnedSelectorModules.add(id);
5554
+ console.warn(`[ngx-dynamic-entity] The common module '${id}' is registered with the selector ` +
5555
+ `'${selector}' rather than a component class, so there is nothing to mount for its ` +
5556
+ `tab. Register the component itself: ` +
5557
+ `{ id: '${id}', label: {...}, component: MyComponent }.`);
4242
5558
  }
4243
5559
  get fieldsForActiveTab() {
4244
5560
  const active = this.activeSubTabConfig ?? this.activeTabConfig;
4245
5561
  if (active?.moduleName)
4246
5562
  return [];
4247
- const rawFields = active ? (active.fields || []) : ((this.config?.tabs || []).flatMap(t => t.fields || []));
5563
+ const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
4248
5564
  const currentValues = this.formValues();
4249
5565
  const hiddenFields = new Set(this.ruleResult().hiddenFields);
4250
5566
  return rawFields.filter(field => evaluateFieldVisibility(field, currentValues) && !this.namesField(hiddenFields, field));
@@ -4454,6 +5770,18 @@ class DynamicFormComponent {
4454
5770
  resolveFieldLabel(field) {
4455
5771
  return resolveLabel(field.label, this.language);
4456
5772
  }
5773
+ /**
5774
+ * The changed critical fields as one comma-separated string.
5775
+ *
5776
+ * Joined here rather than looped in the template because the banner is a sentence with the
5777
+ * list inside it, and a sentence has to reach `UI_TEXT` whole — a translation moves the
5778
+ * list somewhere else in the clause.
5779
+ */
5780
+ changedCriticalFieldLabels() {
5781
+ return this.changedCriticalFields()
5782
+ .map(field => this.resolveFieldLabel(field))
5783
+ .join(', ');
5784
+ }
4457
5785
  // ─── Form construction ────────────────────────────────────────────────────
4458
5786
  buildForm() {
4459
5787
  if (!this.config)
@@ -4504,9 +5832,7 @@ class DynamicFormComponent {
4504
5832
  }
4505
5833
  // Rebuilding must not stack subscriptions on successive config/data changes.
4506
5834
  this.valueSub?.unsubscribe();
4507
- const changes$ = this.changeDebounceMs > 0
4508
- ? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs))
4509
- : this.form.valueChanges;
5835
+ const changes$ = this.changeDebounceMs > 0 ? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs)) : this.form.valueChanges;
4510
5836
  // Validity is not value: an async validator settling flips `pending` and then `invalid`
4511
5837
  // with no value change and no template event, so under OnPush the Save button would
4512
5838
  // never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
@@ -4993,7 +6319,12 @@ class DynamicFormComponent {
4993
6319
  focusActivePanel() {
4994
6320
  if (typeof requestAnimationFrame !== 'function')
4995
6321
  return;
4996
- requestAnimationFrame(() => this.formPanel?.nativeElement?.focus?.());
6322
+ requestAnimationFrame(() =>
6323
+ // `preventScroll` because this focus exists to tell a screen reader the panel changed,
6324
+ // not to move the page. The default scrolls the panel into view, which on a short
6325
+ // viewport jumps the layout — enough that a Save button below it moves out from under
6326
+ // a tap. The panel sits directly beneath the tab strip and is already on screen.
6327
+ this.formPanel?.nativeElement?.focus?.({ preventScroll: true }));
4997
6328
  }
4998
6329
  /** Accessible name for the field panel — it is the tabpanel for the active tab. */
4999
6330
  get activeTabLabel() {
@@ -5008,9 +6339,7 @@ class DynamicFormComponent {
5008
6339
  * filling the form in.
5009
6340
  */
5010
6341
  driftHint(field) {
5011
- const source = field.referencedEntityKey
5012
- ? `"${field.referencedEntityKey}"`
5013
- : 'its source entity';
6342
+ const source = field.referencedEntityKey ? `"${field.referencedEntityKey}"` : 'its source entity';
5014
6343
  return `This field is linked to ${source} and its definition there has changed since it was linked.`;
5015
6344
  }
5016
6345
  resolveTabLabel(tab) {
@@ -5061,11 +6390,11 @@ class DynamicFormComponent {
5061
6390
  this.formReset.emit();
5062
6391
  }
5063
6392
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5064
- 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\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 \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\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 class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\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\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></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 @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 (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\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\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\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 You do not have permission to view this record.\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 });
6393
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5065
6394
  }
5066
6395
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
5067
6396
  type: Component,
5068
- 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\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 \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\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 class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\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\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></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 @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 (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\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\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\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 You do not have permission to view this record.\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"] }]
6397
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media(max-width:768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
5069
6398
  }], propDecorators: { config: [{
5070
6399
  type: Input
5071
6400
  }], rules: [{
@@ -5124,6 +6453,8 @@ const FIELD_NOT_FOUND = Symbol('field-not-found');
5124
6453
  * interactive quick-jump links, and baseline modification tracking.
5125
6454
  */
5126
6455
  class DynamicRecordFormComponent {
6456
+ /** Library chrome, overridable via UI_TEXT. */
6457
+ ui = inject(UiTextService);
5127
6458
  config;
5128
6459
  rules;
5129
6460
  initialData;
@@ -5388,8 +6719,7 @@ class DynamicRecordFormComponent {
5388
6719
  }
5389
6720
  }
5390
6721
  const scoped = this.rulesEvaluation.filterForTab(this.rules, tabId, this.config);
5391
- const ruleErrors = this.rulesEvaluation.evaluate(scoped, form.formValues(), this.originalBaseline())
5392
- .validationErrors;
6722
+ const ruleErrors = this.rulesEvaluation.evaluate(scoped, form.formValues(), this.originalBaseline()).validationErrors;
5393
6723
  Object.assign(errors, ruleErrors);
5394
6724
  if (Object.keys(errors).length > 0) {
5395
6725
  this.sectionErrors.set(errors);
@@ -5556,6 +6886,11 @@ class DynamicRecordFormComponent {
5556
6886
  formatFieldLabel(field) {
5557
6887
  return resolveLabel(field.label, this.language);
5558
6888
  }
6889
+ /** "Add <field> row" / "Edit <field> row" — one key each, because word order moves. */
6890
+ rowDrawerTitle(field) {
6891
+ const key = this.inlineRowIndex() === null ? 'addRowTitle' : 'editRowTitle';
6892
+ return this.ui.text(key, this.language, { field: this.formatFieldLabel(field) });
6893
+ }
5559
6894
  /** Summary values render through the shared core formatter, not a local stringifier. */
5560
6895
  formatFieldValue(field) {
5561
6896
  return formatDisplayValue(field.type, field.options, this.fieldValue(field.id), this.language);
@@ -5615,11 +6950,11 @@ class DynamicRecordFormComponent {
5615
6950
  }, { injector: this.injector });
5616
6951
  }
5617
6952
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5618
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">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\">{{ headerToggleValue ? 'Yes' : 'No' }}</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]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</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 \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\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]=\"'Click to jump to ' + 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\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\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 >+ Row</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 type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</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>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</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()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6953
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5619
6954
  }
5620
6955
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
5621
6956
  type: Component,
5622
- 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\">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\">{{ headerToggleValue ? 'Yes' : 'No' }}</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]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</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 \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\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]=\"'Click to jump to ' + 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\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\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 >+ Row</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 type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</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>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</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()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
6957
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-record-form', standalone: true, imports: [CommonModule, ReactiveFormsModule, DynamicFormComponent, DynamicFieldComponent], template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">{{ ui.text('entityLabel', language, { entity: config.entity }) }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ ui.text(headerToggleValue ? 'yes' : 'no', language) }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"ui.text('dismissBanner', language, { message: banner.message })\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\n \u2715\n </button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n {{ ui.text('recordModified', language) }}\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"ui.text('jumpToField', language, { field: formatFieldLabel(sf) })\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">{{ ui.text('editingSection', language) }}</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n {{ ui.text('saveSection', language) }}\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n {{ ui.text('editSection', language) }}\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >\n {{ ui.text('addRow', language) }}\n </button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\"\n >\n {{ ui.text('edit', language) }}\n </button>\n <button\n type=\"button\"\n class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\"\n >\n {{ ui.text('delete', language) }}\n </button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">{{ ui.text('noRows', language) }}</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ rowDrawerTitle(rowField) }}</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">\n {{ ui.text('cancel', language) }}\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">\n {{ ui.text('saveRow', language) }}\n </button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"] }]
5623
6958
  }], propDecorators: { config: [{
5624
6959
  type: Input
5625
6960
  }], rules: [{
@@ -5661,5 +6996,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
5661
6996
  * Generated bundle index. Do not edit.
5662
6997
  */
5663
6998
 
5664
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, 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_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
6999
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
5665
7000
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map