ngx-dynamic-entity 1.1.0 → 1.3.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.
Files changed (58) hide show
  1. package/README.md +1 -1
  2. package/esm2022/lib/field-types/array-field.component.mjs +13 -5
  3. package/esm2022/lib/field-types/boolean-field.component.mjs +4 -3
  4. package/esm2022/lib/field-types/checkbox-field.component.mjs +4 -3
  5. package/esm2022/lib/field-types/currency-field.component.mjs +4 -3
  6. package/esm2022/lib/field-types/date-field.component.mjs +4 -3
  7. package/esm2022/lib/field-types/date-time-field.component.mjs +138 -0
  8. package/esm2022/lib/field-types/dropdown-field.component.mjs +9 -7
  9. package/esm2022/lib/field-types/email-field.component.mjs +4 -3
  10. package/esm2022/lib/field-types/entity-ref-field.component.mjs +21 -7
  11. package/esm2022/lib/field-types/field-component.contract.mjs +2 -0
  12. package/esm2022/lib/field-types/file-field.component.mjs +4 -3
  13. package/esm2022/lib/field-types/group-field.component.mjs +5 -5
  14. package/esm2022/lib/field-types/image-field.component.mjs +4 -3
  15. package/esm2022/lib/field-types/month-year-field.component.mjs +4 -3
  16. package/esm2022/lib/field-types/multi-select-field.component.mjs +4 -3
  17. package/esm2022/lib/field-types/number-field.component.mjs +7 -11
  18. package/esm2022/lib/field-types/password-field.component.mjs +4 -3
  19. package/esm2022/lib/field-types/radio-field.component.mjs +4 -3
  20. package/esm2022/lib/field-types/text-field.component.mjs +25 -18
  21. package/esm2022/lib/field-types/textarea-field.component.mjs +4 -3
  22. package/esm2022/lib/field-types/time-field.component.mjs +96 -0
  23. package/esm2022/lib/form/dynamic-field/dynamic-field.component.mjs +55 -7
  24. package/esm2022/lib/form/dynamic-form.component.mjs +140 -15
  25. package/esm2022/lib/form/dynamic-record-form.component.mjs +54 -17
  26. package/esm2022/lib/providers/provide-field-types.mjs +5 -2
  27. package/esm2022/lib/providers/provide-ngx-dynamic-entity.mjs +7 -2
  28. package/esm2022/lib/services/cascade-data.service.mjs +2 -2
  29. package/esm2022/lib/services/entity-ref-registry.service.mjs +2 -2
  30. package/esm2022/lib/services/lookup-registry.service.mjs +2 -2
  31. package/esm2022/lib/services/validation-messages.service.mjs +56 -0
  32. package/esm2022/lib/services/validator-registry.service.mjs +24 -2
  33. package/esm2022/lib/tokens/injection-tokens.mjs +19 -6
  34. package/esm2022/public-api.mjs +9 -2
  35. package/fesm2022/ngx-dynamic-entity.mjs +663 -98
  36. package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
  37. package/lib/field-types/array-field.component.d.ts +7 -1
  38. package/lib/field-types/date-time-field.component.d.ts +30 -0
  39. package/lib/field-types/dropdown-field.component.d.ts +1 -0
  40. package/lib/field-types/entity-ref-field.component.d.ts +11 -1
  41. package/lib/field-types/field-component.contract.d.ts +49 -0
  42. package/lib/field-types/group-field.component.d.ts +1 -1
  43. package/lib/field-types/number-field.component.d.ts +1 -0
  44. package/lib/field-types/text-field.component.d.ts +10 -2
  45. package/lib/field-types/time-field.component.d.ts +27 -0
  46. package/lib/form/dynamic-field/dynamic-field.component.d.ts +39 -4
  47. package/lib/form/dynamic-form.component.d.ts +64 -2
  48. package/lib/form/dynamic-record-form.component.d.ts +19 -0
  49. package/lib/providers/provide-ngx-dynamic-entity.d.ts +17 -1
  50. package/lib/services/cascade-data.service.d.ts +1 -1
  51. package/lib/services/entity-ref-registry.service.d.ts +1 -1
  52. package/lib/services/lookup-registry.service.d.ts +1 -1
  53. package/lib/services/validation-messages.service.d.ts +43 -0
  54. package/lib/services/validator-registry.service.d.ts +13 -1
  55. package/lib/tokens/injection-tokens.d.ts +18 -5
  56. package/package.json +2 -2
  57. package/public-api.d.ts +4 -0
  58. package/styles.css +579 -0
@@ -1,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, Input, Component, signal, inject, Injectable, DestroyRef, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, computed, HostListener, Output } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, ChangeDetectorRef, Optional, Inject, EventEmitter, computed, HostListener, ViewChildren, Output, ElementRef, afterNextRender } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
5
- import { resolveLabel, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, getTabPath, applyAutoPatch, applyPatchOnTrue, formatDisplayValue } from '@dynamic-entity/core';
5
+ import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, getTabPath, applyAutoPatch, applyPatchOnTrue } from '@dynamic-entity/core';
6
6
  export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
7
7
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
8
  import { Subject, isObservable, firstValueFrom } from 'rxjs';
@@ -29,7 +29,7 @@ class InMemoryEntityRefCacheStore {
29
29
  }
30
30
  }
31
31
 
32
- /** Roles that see XXXXXXXXX for masked fields (ADR-003) */
32
+ /** Roles that see XXXXXXXXX for masked fields. Presentational only — enforce authz server-side. */
33
33
  const MASKED_ROLES = new InjectionToken('MASKED_ROLES');
34
34
  /**
35
35
  * Consumer **overrides**: fieldType string → Angular component class.
@@ -86,8 +86,25 @@ const LOOKUP_REGISTRY = new InjectionToken('LOOKUP_REGISTRY');
86
86
  * })
87
87
  */
88
88
  const RECORD_MIGRATIONS = new InjectionToken('RECORD_MIGRATIONS');
89
+ /**
90
+ * Overrides for the messages shown under an invalid field, keyed by Angular error key
91
+ * (`required`, `minlength`, `pattern`, …) plus a few field-specific keys.
92
+ *
93
+ * The built-in messages are English literals. Registering here is what makes a non-English
94
+ * form possible without re-implementing every field component; unlisted keys keep their
95
+ * default, so overriding one does not mean supplying them all.
96
+ */
97
+ const VALIDATION_MESSAGES = new InjectionToken('VALIDATION_MESSAGES');
89
98
  /** Registry: validator key → ValidatorFn */
90
99
  const VALIDATOR_REGISTRY = new InjectionToken('VALIDATOR_REGISTRY');
100
+ /**
101
+ * Registry: validator key → AsyncValidatorFn.
102
+ *
103
+ * Kept separate from VALIDATOR_REGISTRY because Angular applies the two differently: an async
104
+ * validator runs only once the synchronous ones pass, and puts the control into `pending`
105
+ * while it does. Name them from a schema with `validators: { customAsync: ['uniqueEmail'] }`.
106
+ */
107
+ const ASYNC_VALIDATOR_REGISTRY = new InjectionToken('ASYNC_VALIDATOR_REGISTRY');
91
108
  /** Registry: hook key → async (data, context) => data */
92
109
  const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
93
110
  /**
@@ -125,10 +142,6 @@ const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
125
142
  * { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
126
143
  */
127
144
  const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', { providedIn: 'root', factory: () => new InMemoryEntityRefCacheStore() });
128
- /**
129
- * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
130
- * Defaults to allowing edits if not provided.
131
- */
132
145
  /**
133
146
  * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
134
147
  * Defaults to allowing edits if not provided.
@@ -156,16 +169,90 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
156
169
  { provide: ENTITY_REF_REGISTRY, useValue: new Map(Object.entries(config.entityRefs ?? {})) },
157
170
  { provide: LOOKUP_REGISTRY, useValue: new Map(Object.entries(config.lookups ?? {})) },
158
171
  { provide: VALIDATOR_REGISTRY, useValue: new Map(Object.entries(config.validators ?? {})) },
172
+ {
173
+ provide: ASYNC_VALIDATOR_REGISTRY,
174
+ useValue: new Map(Object.entries(config.asyncValidators ?? {})),
175
+ },
159
176
  { provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
160
177
  { provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
178
+ { provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
161
179
  ]);
162
180
 
181
+ /** The built-in English messages. Every key here can be overridden individually. */
182
+ const DEFAULT_VALIDATION_MESSAGES = {
183
+ required: 'This field is required.',
184
+ requiredSelection: 'Please select an option.',
185
+ email: 'Please enter a valid email address.',
186
+ pattern: 'Invalid format.',
187
+ minlength: (_lang, err) => `Minimum ${err?.requiredLength} characters required.`,
188
+ maxlength: (_lang, err) => `Maximum ${err?.requiredLength} characters allowed.`,
189
+ min: (_lang, err) => `Value must be at least ${err?.min}.`,
190
+ max: (_lang, err) => `Value must not exceed ${err?.max}.`,
191
+ invalid: 'Invalid value.',
192
+ invalidNumber: 'Invalid number.',
193
+ invalidSelection: 'Invalid selection.',
194
+ };
195
+ class ValidationMessagesService {
196
+ overrides = inject(VALIDATION_MESSAGES, { optional: true }) ?? {};
197
+ /**
198
+ * The message for the first error present, in the order the caller lists.
199
+ *
200
+ * Order is the caller's because it is field-specific: a dropdown wants
201
+ * `requiredSelection` where a text input wants `required`, and the first match wins so the
202
+ * most specific message is the one shown.
203
+ */
204
+ resolve(errors, language, order, fallbackKey = 'invalid') {
205
+ if (!errors)
206
+ return '';
207
+ for (const entry of order) {
208
+ // A tuple maps an Angular error key to a different message key: a dropdown raises the
209
+ // standard `required` error but reads better as "Please select an option", and both
210
+ // messages stay independently overridable.
211
+ const [errorKey, messageKey] = typeof entry === 'string' ? [entry, entry] : entry;
212
+ // `errors[errorKey]` is usually an object of detail, but `required` is simply `true`.
213
+ if (errors[errorKey] === undefined)
214
+ continue;
215
+ return this.messageFor(messageKey, language, errors[errorKey]);
216
+ }
217
+ return this.messageFor(fallbackKey, language, null);
218
+ }
219
+ /** One message by key, honouring an override before the default. */
220
+ messageFor(key, language, error) {
221
+ const message = this.overrides[key] ?? DEFAULT_VALIDATION_MESSAGES[key];
222
+ if (message === undefined)
223
+ return '';
224
+ return typeof message === 'function' ? message(language, error) : message;
225
+ }
226
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
227
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, providedIn: 'root' });
228
+ }
229
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, decorators: [{
230
+ type: Injectable,
231
+ args: [{ providedIn: 'root' }]
232
+ }] });
233
+
163
234
  class TextFieldComponent {
235
+ messages = inject(ValidationMessagesService);
236
+ displaySub;
164
237
  field;
165
- control;
238
+ set control(value) {
239
+ this._control = value;
240
+ this.displaySub?.unsubscribe();
241
+ this.displayValue.set(value?.value ?? null);
242
+ this.displaySub = value?.valueChanges.subscribe(v => this.displayValue.set(v));
243
+ }
244
+ get control() {
245
+ return this._control;
246
+ }
247
+ _control;
166
248
  language = 'en';
167
249
  readonly = false;
168
250
  masked = false;
251
+ /** Readonly markup reads this signal so an external patch (autoPatch) is visible under OnPush. */
252
+ displayValue = signal(null);
253
+ ngOnDestroy() {
254
+ this.displaySub?.unsubscribe();
255
+ }
169
256
  get label() {
170
257
  return resolveLabel(this.field?.label, this.language);
171
258
  }
@@ -175,18 +262,7 @@ class TextFieldComponent {
175
262
  get errorMessage() {
176
263
  if (!this.control || !this.control.errors || !this.control.touched)
177
264
  return '';
178
- const errs = this.control.errors;
179
- if (errs['required'])
180
- return 'This field is required.';
181
- if (errs['email'])
182
- return 'Please enter a valid email address.';
183
- if (errs['minlength'])
184
- return `Minimum ${errs['minlength'].requiredLength} characters required.`;
185
- if (errs['maxlength'])
186
- return `Maximum ${errs['maxlength'].requiredLength} characters allowed.`;
187
- if (errs['pattern'])
188
- return 'Invalid format.';
189
- return 'Invalid value.';
265
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'email', 'minlength', 'maxlength', 'pattern']);
190
266
  }
191
267
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
192
268
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -201,7 +277,7 @@ class TextFieldComponent {
201
277
  @if (masked) {
202
278
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
203
279
  } @else if (readonly) {
204
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
280
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
205
281
  } @else {
206
282
  <input
207
283
  [id]="field.id"
@@ -218,11 +294,12 @@ class TextFieldComponent {
218
294
  }
219
295
  }
220
296
  </div>
221
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
297
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
222
298
  }
223
299
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, decorators: [{
224
300
  type: Component,
225
301
  args: [{
302
+ changeDetection: ChangeDetectionStrategy.OnPush,
226
303
  selector: 'ngx-text-field',
227
304
  standalone: true,
228
305
  imports: [ReactiveFormsModule],
@@ -238,7 +315,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
238
315
  @if (masked) {
239
316
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
240
317
  } @else if (readonly) {
241
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
318
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
242
319
  } @else {
243
320
  <input
244
321
  [id]="field.id"
@@ -304,11 +381,12 @@ class TextareaFieldComponent {
304
381
  }
305
382
  }
306
383
  </div>
307
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
384
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
308
385
  }
309
386
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, decorators: [{
310
387
  type: Component,
311
388
  args: [{
389
+ changeDetection: ChangeDetectionStrategy.OnPush,
312
390
  selector: 'ngx-textarea-field',
313
391
  standalone: true,
314
392
  imports: [ReactiveFormsModule],
@@ -349,6 +427,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
349
427
  }] } });
350
428
 
351
429
  class NumberFieldComponent {
430
+ messages = inject(ValidationMessagesService);
352
431
  field;
353
432
  control;
354
433
  language = 'en';
@@ -363,14 +442,7 @@ class NumberFieldComponent {
363
442
  get errorMessage() {
364
443
  if (!this.control || !this.control.errors || !this.control.touched)
365
444
  return '';
366
- const errs = this.control.errors;
367
- if (errs['required'])
368
- return 'This field is required.';
369
- if (errs['min'])
370
- return `Value must be at least ${errs['min'].min}.`;
371
- if (errs['max'])
372
- return `Value must not exceed ${errs['max'].max}.`;
373
- return 'Invalid number.';
445
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'min', 'max'], 'invalidNumber');
374
446
  }
375
447
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
376
448
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -405,11 +477,12 @@ class NumberFieldComponent {
405
477
  }
406
478
  }
407
479
  </div>
408
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][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"] }] });
480
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][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 });
409
481
  }
410
482
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, decorators: [{
411
483
  type: Component,
412
484
  args: [{
485
+ changeDetection: ChangeDetectionStrategy.OnPush,
413
486
  selector: 'ngx-number-field',
414
487
  standalone: true,
415
488
  imports: [ReactiveFormsModule],
@@ -508,11 +581,12 @@ class CurrencyFieldComponent {
508
581
  }
509
582
  }
510
583
  </div>
511
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][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"] }] });
584
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][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 });
512
585
  }
513
586
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CurrencyFieldComponent, decorators: [{
514
587
  type: Component,
515
588
  args: [{
589
+ changeDetection: ChangeDetectionStrategy.OnPush,
516
590
  selector: 'ngx-currency-field',
517
591
  standalone: true,
518
592
  imports: [ReactiveFormsModule],
@@ -599,11 +673,12 @@ class EmailFieldComponent {
599
673
  }
600
674
  }
601
675
  </div>
602
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
676
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
603
677
  }
604
678
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EmailFieldComponent, decorators: [{
605
679
  type: Component,
606
680
  args: [{
681
+ changeDetection: ChangeDetectionStrategy.OnPush,
607
682
  selector: 'ngx-email-field',
608
683
  standalone: true,
609
684
  imports: [ReactiveFormsModule],
@@ -701,11 +776,12 @@ class PasswordFieldComponent {
701
776
  }
702
777
  }
703
778
  </div>
704
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
779
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
705
780
  }
706
781
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PasswordFieldComponent, decorators: [{
707
782
  type: Component,
708
783
  args: [{
784
+ changeDetection: ChangeDetectionStrategy.OnPush,
709
785
  selector: 'ngx-password-field',
710
786
  standalone: true,
711
787
  imports: [ReactiveFormsModule],
@@ -787,11 +863,12 @@ class CheckboxFieldComponent {
787
863
  </label>
788
864
  }
789
865
  </div>
790
- `, 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"] }] });
866
+ `, 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 });
791
867
  }
792
868
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, decorators: [{
793
869
  type: Component,
794
870
  args: [{
871
+ changeDetection: ChangeDetectionStrategy.OnPush,
795
872
  selector: 'ngx-checkbox-field',
796
873
  standalone: true,
797
874
  imports: [ReactiveFormsModule],
@@ -875,11 +952,12 @@ class BooleanFieldComponent {
875
952
  </div>
876
953
  }
877
954
  </div>
878
- `, 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"] }] });
955
+ `, 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 });
879
956
  }
880
957
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BooleanFieldComponent, decorators: [{
881
958
  type: Component,
882
959
  args: [{
960
+ changeDetection: ChangeDetectionStrategy.OnPush,
883
961
  selector: 'ngx-boolean-field',
884
962
  standalone: true,
885
963
  imports: [ReactiveFormsModule],
@@ -968,11 +1046,12 @@ class DateFieldComponent {
968
1046
  }
969
1047
  }
970
1048
  </div>
971
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1049
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
972
1050
  }
973
1051
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, decorators: [{
974
1052
  type: Component,
975
1053
  args: [{
1054
+ changeDetection: ChangeDetectionStrategy.OnPush,
976
1055
  selector: 'ngx-date-field',
977
1056
  standalone: true,
978
1057
  imports: [ReactiveFormsModule],
@@ -1011,6 +1090,231 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1011
1090
  type: Input
1012
1091
  }] } });
1013
1092
 
1093
+ /**
1094
+ * Reads a stored value into a `Date`.
1095
+ *
1096
+ * A bare calendar date is pinned to *local* midnight. `new Date('2020-01-01')` reads a
1097
+ * date-only string as UTC midnight, which renders as the previous day anywhere west of
1098
+ * Greenwich — and every legacy `datetime` value has exactly that shape, because the type
1099
+ * used to render through `DateFieldComponent` and its `type="date"` input.
1100
+ */
1101
+ function parseStored(value) {
1102
+ if (value === null || value === undefined || value === '')
1103
+ return null;
1104
+ if (value instanceof Date)
1105
+ return Number.isNaN(value.getTime()) ? null : value;
1106
+ const raw = String(value);
1107
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw);
1108
+ if (dateOnly)
1109
+ return new Date(+dateOnly[1], +dateOnly[2] - 1, +dateOnly[3]);
1110
+ const parsed = new Date(raw);
1111
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
1112
+ }
1113
+ const pad = (n) => String(n).padStart(2, '0');
1114
+ /**
1115
+ * Date & Time field: a single `datetime-local` input.
1116
+ *
1117
+ * The type is stored as ISO 8601 UTC, per the same convention `date` follows, while the
1118
+ * input works in the viewer's local zone — `datetime-local` has no offset, so the two
1119
+ * conversions here are what keep a value stable across a round trip.
1120
+ */
1121
+ class DateTimeFieldComponent {
1122
+ field;
1123
+ control;
1124
+ language = 'en';
1125
+ readonly = false;
1126
+ masked = false;
1127
+ get label() {
1128
+ return resolveLabel(this.field?.label, this.language);
1129
+ }
1130
+ /** Stored ISO 8601 → the `YYYY-MM-DDTHH:mm` local form the input requires. */
1131
+ get inputValue() {
1132
+ const d = parseStored(this.control?.value);
1133
+ if (!d)
1134
+ return '';
1135
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
1136
+ }
1137
+ /** The input's local `YYYY-MM-DDTHH:mm` → stored ISO 8601 UTC. */
1138
+ onInput(value) {
1139
+ if (!value) {
1140
+ this.control.setValue(null);
1141
+ this.control.markAsTouched();
1142
+ return;
1143
+ }
1144
+ const d = new Date(value);
1145
+ // An unparseable value is stored verbatim rather than silently discarded, so a validator
1146
+ // sees what the user actually entered.
1147
+ this.control.setValue(Number.isNaN(d.getTime()) ? value : d.toISOString());
1148
+ this.control.markAsTouched();
1149
+ }
1150
+ /**
1151
+ * Displays date *and* time. `DateFieldComponent` used `toLocaleDateString()` here, so a
1152
+ * datetime read back through the field showed no time while the same value in the record
1153
+ * summary — which goes through core's `formatDisplayValue` — showed it.
1154
+ */
1155
+ formatDateTime(value) {
1156
+ const d = parseStored(value);
1157
+ return d ? d.toLocaleString() : '—';
1158
+ }
1159
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateTimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1160
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DateTimeFieldComponent, isStandalone: true, selector: "ngx-date-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
1161
+ <div class="ngx-field ngx-field--datetime"
1162
+ [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1163
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
1164
+ @if (masked) {
1165
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1166
+ } @else if (readonly) {
1167
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
1168
+ } @else {
1169
+ <input
1170
+ [id]="field.id"
1171
+ class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1172
+ type="datetime-local"
1173
+ [value]="inputValue"
1174
+ (change)="onInput($any($event.target).value)"
1175
+ [attr.disabled]="field.disabled ? true : null"
1176
+ />
1177
+ @if (control.invalid && control.touched) {
1178
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1179
+ }
1180
+ }
1181
+ </div>
1182
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1183
+ }
1184
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateTimeFieldComponent, decorators: [{
1185
+ type: Component,
1186
+ args: [{
1187
+ changeDetection: ChangeDetectionStrategy.OnPush,
1188
+ selector: 'ngx-date-time-field',
1189
+ standalone: true,
1190
+ imports: [ReactiveFormsModule],
1191
+ template: `
1192
+ <div class="ngx-field ngx-field--datetime"
1193
+ [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1194
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
1195
+ @if (masked) {
1196
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1197
+ } @else if (readonly) {
1198
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatDateTime(control.value) }}</span>
1199
+ } @else {
1200
+ <input
1201
+ [id]="field.id"
1202
+ class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1203
+ type="datetime-local"
1204
+ [value]="inputValue"
1205
+ (change)="onInput($any($event.target).value)"
1206
+ [attr.disabled]="field.disabled ? true : null"
1207
+ />
1208
+ @if (control.invalid && control.touched) {
1209
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1210
+ }
1211
+ }
1212
+ </div>
1213
+ `,
1214
+ }]
1215
+ }], propDecorators: { field: [{
1216
+ type: Input
1217
+ }], control: [{
1218
+ type: Input
1219
+ }], language: [{
1220
+ type: Input
1221
+ }], readonly: [{
1222
+ type: Input
1223
+ }], masked: [{
1224
+ type: Input
1225
+ }] } });
1226
+
1227
+ /**
1228
+ * Time field: a bare time of day, with no date and no zone attached.
1229
+ *
1230
+ * Stored as `HH:mm` — exactly the value `<input type="time">` reads and writes, so the
1231
+ * control binds straight through with no conversion. That is the whole reason this is a
1232
+ * separate type from `datetime`: a recurring 09:00 opening time is not a moment in time,
1233
+ * and converting it to UTC would move it whenever the offset changed.
1234
+ */
1235
+ class TimeFieldComponent {
1236
+ field;
1237
+ control;
1238
+ language = 'en';
1239
+ readonly = false;
1240
+ masked = false;
1241
+ get label() {
1242
+ return resolveLabel(this.field?.label, this.language);
1243
+ }
1244
+ /**
1245
+ * Defers to core rather than parsing `HH:mm` locally. The other date-ish components each
1246
+ * grew their own formatter, and `datetime` ended up showing one thing in the field and
1247
+ * another in the record summary because the two drifted apart.
1248
+ */
1249
+ formatTime(value) {
1250
+ return formatDisplayValue('time', undefined, value, this.language);
1251
+ }
1252
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TimeFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1253
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TimeFieldComponent, isStandalone: true, selector: "ngx-time-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
1254
+ <div class="ngx-field ngx-field--time"
1255
+ [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1256
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
1257
+ @if (masked) {
1258
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1259
+ } @else if (readonly) {
1260
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
1261
+ } @else {
1262
+ <input
1263
+ [id]="field.id"
1264
+ class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1265
+ type="time"
1266
+ [formControl]="$any(control)"
1267
+ [attr.disabled]="field.disabled ? true : null"
1268
+ />
1269
+ @if (control.invalid && control.touched) {
1270
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1271
+ }
1272
+ }
1273
+ </div>
1274
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { 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 });
1275
+ }
1276
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TimeFieldComponent, decorators: [{
1277
+ type: Component,
1278
+ args: [{
1279
+ changeDetection: ChangeDetectionStrategy.OnPush,
1280
+ selector: 'ngx-time-field',
1281
+ standalone: true,
1282
+ imports: [ReactiveFormsModule],
1283
+ template: `
1284
+ <div class="ngx-field ngx-field--time"
1285
+ [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
1286
+ <label class="ngx-field__label" [attr.for]="field.id">{{ label }}</label>
1287
+ @if (masked) {
1288
+ <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
1289
+ } @else if (readonly) {
1290
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ formatTime(control.value) }}</span>
1291
+ } @else {
1292
+ <input
1293
+ [id]="field.id"
1294
+ class="ngx-field__input" [attr.data-testid]="'field-' + field.id + '-input'"
1295
+ type="time"
1296
+ [formControl]="$any(control)"
1297
+ [attr.disabled]="field.disabled ? true : null"
1298
+ />
1299
+ @if (control.invalid && control.touched) {
1300
+ <span class="ngx-field__error" [attr.data-testid]="'field-' + field.id + '-error'">This field has an error</span>
1301
+ }
1302
+ }
1303
+ </div>
1304
+ `,
1305
+ }]
1306
+ }], propDecorators: { field: [{
1307
+ type: Input
1308
+ }], control: [{
1309
+ type: Input
1310
+ }], language: [{
1311
+ type: Input
1312
+ }], readonly: [{
1313
+ type: Input
1314
+ }], masked: [{
1315
+ type: Input
1316
+ }] } });
1317
+
1014
1318
  const MONTH_NAMES = [
1015
1319
  'January', 'February', 'March', 'April', 'May', 'June',
1016
1320
  'July', 'August', 'September', 'October', 'November', 'December',
@@ -1097,11 +1401,12 @@ class MonthYearFieldComponent {
1097
1401
  }
1098
1402
  }
1099
1403
  </div>
1100
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }] });
1404
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1101
1405
  }
1102
1406
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MonthYearFieldComponent, decorators: [{
1103
1407
  type: Component,
1104
1408
  args: [{
1409
+ changeDetection: ChangeDetectionStrategy.OnPush,
1105
1410
  selector: 'ngx-month-year-field',
1106
1411
  standalone: true,
1107
1412
  imports: [ReactiveFormsModule],
@@ -1372,7 +1677,7 @@ const hostLastConfig = new WeakMap();
1372
1677
  * Point a choice component's `options` signal at the right source (parity plan §6.3).
1373
1678
  *
1374
1679
  * Called from the `field` / `language` input setters of `dropdown`, `radio` and `multiSelect` —
1375
- * the wiring is theirs, the decision is the service's, which is what keeps the ADR-008
1680
+ * the wiring is theirs, the decision is the service's, which is what keeps the field
1376
1681
  * five-input contract intact: options are never pushed in as a sixth input.
1377
1682
  *
1378
1683
  * Setters rather than `ngOnChanges` on purpose. `ngOnChanges` only fires for inputs Angular
@@ -1401,6 +1706,7 @@ function refreshChoiceOptions(host, lookups) {
1401
1706
 
1402
1707
  class DropdownFieldComponent {
1403
1708
  lookups = inject(LookupRegistryService);
1709
+ messages = inject(ValidationMessagesService);
1404
1710
  _field;
1405
1711
  _language = 'en';
1406
1712
  /** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
@@ -1459,10 +1765,9 @@ class DropdownFieldComponent {
1459
1765
  get errorMessage() {
1460
1766
  if (!this.control || !this.control.errors || !this.control.touched)
1461
1767
  return '';
1462
- const errs = this.control.errors;
1463
- if (errs['required'])
1464
- return 'Please select an option.';
1465
- return 'Invalid selection.';
1768
+ // `requiredSelection` rather than `required`: "Please select an option" reads better on a
1769
+ // dropdown than "This field is required", and both stay independently overridable.
1770
+ return this.messages.resolve(this.control.errors, this.language, [['required', 'requiredSelection']], 'invalidSelection');
1466
1771
  }
1467
1772
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1468
1773
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1505,11 +1810,12 @@ class DropdownFieldComponent {
1505
1810
  }
1506
1811
  }
1507
1812
  </div>
1508
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1813
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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 });
1509
1814
  }
1510
1815
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, decorators: [{
1511
1816
  type: Component,
1512
1817
  args: [{
1818
+ changeDetection: ChangeDetectionStrategy.OnPush,
1513
1819
  selector: 'ngx-dropdown-field',
1514
1820
  standalone: true,
1515
1821
  imports: [ReactiveFormsModule],
@@ -1660,11 +1966,12 @@ class RadioFieldComponent {
1660
1966
  }
1661
1967
  </fieldset>
1662
1968
  </div>
1663
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1969
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { 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 });
1664
1970
  }
1665
1971
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RadioFieldComponent, decorators: [{
1666
1972
  type: Component,
1667
1973
  args: [{
1974
+ changeDetection: ChangeDetectionStrategy.OnPush,
1668
1975
  selector: 'ngx-radio-field',
1669
1976
  standalone: true,
1670
1977
  imports: [ReactiveFormsModule],
@@ -1799,11 +2106,12 @@ class MultiSelectFieldComponent {
1799
2106
  }
1800
2107
  }
1801
2108
  </div>
1802
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
2109
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { 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 });
1803
2110
  }
1804
2111
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, decorators: [{
1805
2112
  type: Component,
1806
2113
  args: [{
2114
+ changeDetection: ChangeDetectionStrategy.OnPush,
1807
2115
  selector: 'ngx-multi-select-field',
1808
2116
  standalone: true,
1809
2117
  imports: [ReactiveFormsModule],
@@ -1854,7 +2162,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1854
2162
 
1855
2163
  /**
1856
2164
  * EntityRefRegistryService — resolves entity-ref option loader functions.
1857
- * ADR-006: Entity-ref options come ONLY from this registry.
2165
+ * Entity-ref options come ONLY from this registry.
1858
2166
  * Never pass loaders via @Input() on field components.
1859
2167
  *
1860
2168
  * Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
@@ -2046,7 +2354,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2046
2354
  /**
2047
2355
  * CascadeDataService — loads entity-reference options and applies parent→child cascades.
2048
2356
  *
2049
- * Loaders come from the registry only (ADR-006). This service owns the three things a
2357
+ * Loaders come from the registry only. This service owns the three things a
2050
2358
  * field component should not: resolving the loader, coercing whatever it returned
2051
2359
  * (array | Promise | Observable) into a Promise, and filtering by the parent's value.
2052
2360
  */
@@ -2226,6 +2534,9 @@ class EntityRefFieldComponent {
2226
2534
  return;
2227
2535
  void this.reload();
2228
2536
  this.watchParent();
2537
+ this.control?.valueChanges
2538
+ .pipe(takeUntilDestroyed(this.destroyRef))
2539
+ .subscribe(value => this.publishSelection(String(value ?? '')));
2229
2540
  }
2230
2541
  /** Reload options against the parent's current value. */
2231
2542
  async reload() {
@@ -2243,8 +2554,20 @@ class EntityRefFieldComponent {
2243
2554
  this.loading.set(false);
2244
2555
  }
2245
2556
  }
2246
- onSelectionChange() {
2247
- const selected = this.options().find(o => String(o.value) === String(this.control.value));
2557
+ /**
2558
+ * Publish the selected option so autoPatch can copy from its record.
2559
+ *
2560
+ * Driven from `valueChanges` rather than the select's `change` event: that event is the
2561
+ * same one the `formControl` directive listens to, so reading the control inside a
2562
+ * template `(change)` handler can see the previous value. `valueChanges` fires after the
2563
+ * model has updated — including Playwright `selectOption`, which updates the control
2564
+ * through the value accessor.
2565
+ */
2566
+ onSelectionChange(value) {
2567
+ this.publishSelection(value ?? String(this.control?.value ?? ''));
2568
+ }
2569
+ publishSelection(current) {
2570
+ const selected = this.options().find(o => String(o.value) === current);
2248
2571
  this.selectionBus.emit(this.field.id, selected ?? null);
2249
2572
  }
2250
2573
  getLabel(value) {
@@ -2293,7 +2616,6 @@ class EntityRefFieldComponent {
2293
2616
  [id]="'field-' + field.id"
2294
2617
  [formControl]="$any(control)"
2295
2618
  [attr.disabled]="field.disabled ? true : null"
2296
- (change)="onSelectionChange()"
2297
2619
  >
2298
2620
  <option value="">{{ placeholder || 'Select...' }}</option>
2299
2621
  @for (option of options(); track option.value) {
@@ -2309,11 +2631,12 @@ class EntityRefFieldComponent {
2309
2631
  }
2310
2632
  }
2311
2633
  </div>
2312
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
2634
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { 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 });
2313
2635
  }
2314
2636
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, decorators: [{
2315
2637
  type: Component,
2316
2638
  args: [{
2639
+ changeDetection: ChangeDetectionStrategy.OnPush,
2317
2640
  selector: 'ngx-entity-ref-field',
2318
2641
  standalone: true,
2319
2642
  imports: [ReactiveFormsModule],
@@ -2334,7 +2657,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2334
2657
  [id]="'field-' + field.id"
2335
2658
  [formControl]="$any(control)"
2336
2659
  [attr.disabled]="field.disabled ? true : null"
2337
- (change)="onSelectionChange()"
2338
2660
  >
2339
2661
  <option value="">{{ placeholder || 'Select...' }}</option>
2340
2662
  @for (option of options(); track option.value) {
@@ -2460,7 +2782,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2460
2782
 
2461
2783
  /**
2462
2784
  * DynamicFieldComponent — mounts the correct field component for a given NestedFieldConfig.
2463
- * Uses ViewContainerRef.createComponent() and passes inputs via setInput() (ADR-008).
2785
+ * Uses ViewContainerRef.createComponent() and passes inputs via setInput(); see
2786
+ * DynamicFieldComponentContract for the five inputs every field component receives.
2464
2787
  * Never add field-type-specific logic here — this component must remain generic.
2465
2788
  *
2466
2789
  * The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
@@ -2477,6 +2800,12 @@ class DynamicFieldComponent {
2477
2800
  fieldHost;
2478
2801
  fieldRegistry = inject(FieldRegistryService);
2479
2802
  rbacService = inject(RbacService);
2803
+ /**
2804
+ * Passed into `createComponent` so a hosted field sees this host's injector — including
2805
+ * `DynamicFormComponent`'s scoped `EntityRefSelectionService`. Without it, a standalone
2806
+ * field resolves the root bus and `autoPatch` never runs.
2807
+ */
2808
+ injector = inject(Injector);
2480
2809
  componentRef = null;
2481
2810
  ngOnChanges(_changes) {
2482
2811
  this.mountField();
@@ -2498,9 +2827,12 @@ class DynamicFieldComponent {
2498
2827
  return;
2499
2828
  }
2500
2829
  this.fieldHost.clear();
2501
- this.componentRef = this.fieldHost.createComponent(ComponentClass);
2830
+ this.componentRef = this.fieldHost.createComponent(ComponentClass, {
2831
+ injector: this.injector,
2832
+ });
2502
2833
  this.setInputs(this.componentRef, masked);
2503
- this.componentRef.changeDetectorRef.markForCheck();
2834
+ this.watchControl();
2835
+ this.componentRef.changeDetectorRef.detectChanges();
2504
2836
  }
2505
2837
  /**
2506
2838
  * Field types are opt-in (see `provideFieldTypes` / `provideBuiltInFieldTypes`), so an
@@ -2516,7 +2848,44 @@ class DynamicFieldComponent {
2516
2848
  }
2517
2849
  static warnedTypes = new Set();
2518
2850
  /**
2519
- * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types (ADR-008).
2851
+ * Re-check the hosted field component.
2852
+ *
2853
+ * The field components are OnPush, so they re-render on an input change, an event from
2854
+ * their own template, or a signal they read. `form.markAllAsTouched()` is none of those —
2855
+ * it flips `touched` from outside, and every field's error message depends on it. Without
2856
+ * this, a blocked submit would mark the form touched and no error would appear.
2857
+ */
2858
+ refresh() {
2859
+ this.componentRef?.changeDetectorRef.detectChanges();
2860
+ }
2861
+ /**
2862
+ * Re-check the hosted component whenever its control changes from outside.
2863
+ *
2864
+ * The field components are OnPush, so they re-render on an input change or an event from
2865
+ * their own template. A control mutated externally is neither — and that is not an edge
2866
+ * case: patchForm, reset, autoPatch and patchOnTrue all do exactly that. Without this, a
2867
+ * field patched from a record would keep rendering its previous value.
2868
+ *
2869
+ * `detectChanges()` rather than `markForCheck()`: a selection that arrives from a native
2870
+ * `change` event (or Playwright) may not be followed by another Angular tick, so a dirty
2871
+ * flag would never be flushed. Running CD on the hosted component now is the copy showing up.
2872
+ *
2873
+ * Done here rather than in each of the eighteen field components: the host owns the
2874
+ * component reference, so one subscription covers every type including custom ones.
2875
+ */
2876
+ watchControl() {
2877
+ this.controlSub?.unsubscribe();
2878
+ if (!this.control)
2879
+ return;
2880
+ this.controlSub = this.control.valueChanges.subscribe(() => this.refresh());
2881
+ }
2882
+ controlSub;
2883
+ ngOnDestroy() {
2884
+ this.controlSub?.unsubscribe();
2885
+ }
2886
+ /**
2887
+ * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types.
2888
+ * The public shape is DynamicFieldComponentContract.
2520
2889
  * Must use setInput(), not property assignment: the field components declare inputs with
2521
2890
  * definite assignment and no initializer, and the lib compiles with
2522
2891
  * useDefineForClassFields:false, so `'field' in instance` is false until first set.
@@ -2529,11 +2898,12 @@ class DynamicFieldComponent {
2529
2898
  ref.setInput('masked', masked);
2530
2899
  }
2531
2900
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2532
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true });
2901
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "17.3.12", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
2533
2902
  }
2534
2903
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFieldComponent, decorators: [{
2535
2904
  type: Component,
2536
2905
  args: [{
2906
+ changeDetection: ChangeDetectionStrategy.OnPush,
2537
2907
  selector: 'ngx-dynamic-field',
2538
2908
  standalone: true,
2539
2909
  template: `<ng-container #fieldHost></ng-container>`,
@@ -2557,7 +2927,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2557
2927
  args: ['fieldHost', { read: ViewContainerRef, static: true }]
2558
2928
  }] } });
2559
2929
 
2560
- /** ADR-008: GroupFieldComponent — renders nested fieldset container for group.children */
2930
+ /** Renders a nested fieldset container for a group field's children. */
2561
2931
  class GroupFieldComponent {
2562
2932
  field;
2563
2933
  control;
@@ -2597,11 +2967,11 @@ class GroupFieldComponent {
2597
2967
  </div>
2598
2968
  }
2599
2969
  </fieldset>
2600
- `, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
2970
+ `, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2601
2971
  }
2602
2972
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: GroupFieldComponent, decorators: [{
2603
2973
  type: Component,
2604
- args: [{ selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2974
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2605
2975
  <fieldset class="ngx-field ngx-field--group"
2606
2976
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2607
2977
  <legend class="ngx-field__legend">{{ label }}</legend>
@@ -2646,6 +3016,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2646
3016
  */
2647
3017
  class ValidatorRegistryService {
2648
3018
  consumerRegistry = inject(VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
3019
+ asyncRegistry = inject(ASYNC_VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
2649
3020
  /**
2650
3021
  * Resolve a validator key string to a ValidatorFn.
2651
3022
  * Supports parameterized built-ins like 'min:0', 'maxLength:255'.
@@ -2706,6 +3077,27 @@ class ValidatorRegistryService {
2706
3077
  }
2707
3078
  return fnList;
2708
3079
  }
3080
+ /**
3081
+ * Resolve the async validators a field names, in `validators.customAsync`.
3082
+ *
3083
+ * Async validators are deliberately a separate registry and a separate config key: Angular
3084
+ * attaches them through `setAsyncValidators`, runs them only once the synchronous ones
3085
+ * pass, and holds the control in `pending` until they settle. Mixing them into the
3086
+ * synchronous list would silently never run them.
3087
+ *
3088
+ * An unknown name is skipped rather than throwing, matching the synchronous behaviour.
3089
+ */
3090
+ resolveAsyncFromConfig(config) {
3091
+ if (!config || Array.isArray(config))
3092
+ return [];
3093
+ const out = [];
3094
+ for (const key of config.customAsync ?? []) {
3095
+ const fn = this.asyncRegistry.get(key);
3096
+ if (fn)
3097
+ out.push(fn);
3098
+ }
3099
+ return out;
3100
+ }
2709
3101
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2710
3102
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
2711
3103
  }
@@ -2714,8 +3106,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2714
3106
  args: [{ providedIn: 'root' }]
2715
3107
  }] });
2716
3108
 
2717
- /** ADR-008: ArrayFieldComponent — renders repeating rows for FormArray with + Add Item and Remove */
3109
+ /** Renders repeating rows for a FormArray, with Add Item and Remove. */
2718
3110
  class ArrayFieldComponent {
3111
+ /**
3112
+ * addItem/removeItem are public and may be called from outside this component's template,
3113
+ * which under OnPush would not re-render. The host also watches the control, but this
3114
+ * keeps the component correct on its own.
3115
+ */
3116
+ cdr = inject(ChangeDetectorRef);
2719
3117
  field;
2720
3118
  control;
2721
3119
  config;
@@ -2742,9 +3140,11 @@ class ArrayFieldComponent {
2742
3140
  }
2743
3141
  const group = this.fb.group(controls);
2744
3142
  this.formArray.push(group);
3143
+ this.cdr.markForCheck();
2745
3144
  }
2746
3145
  removeItem(index) {
2747
3146
  this.formArray.removeAt(index);
3147
+ this.cdr.markForCheck();
2748
3148
  }
2749
3149
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2750
3150
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", 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: `
@@ -2797,11 +3197,11 @@ class ArrayFieldComponent {
2797
3197
  </div>
2798
3198
  }
2799
3199
  </div>
2800
- `, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
3200
+ `, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2801
3201
  }
2802
3202
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, decorators: [{
2803
3203
  type: Component,
2804
- args: [{ selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
3204
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2805
3205
  <div class="ngx-field ngx-field--array"
2806
3206
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2807
3207
  <div class="ngx-field__array-header">
@@ -3052,11 +3452,12 @@ class ImageFieldComponent {
3052
3452
  }
3053
3453
  }
3054
3454
  </div>
3055
- `, isInline: true });
3455
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3056
3456
  }
3057
3457
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImageFieldComponent, decorators: [{
3058
3458
  type: Component,
3059
3459
  args: [{
3460
+ changeDetection: ChangeDetectionStrategy.OnPush,
3060
3461
  selector: 'ngx-image-field',
3061
3462
  standalone: true,
3062
3463
  imports: [],
@@ -3206,11 +3607,12 @@ class FileFieldComponent {
3206
3607
  }
3207
3608
  }
3208
3609
  </div>
3209
- `, isInline: true });
3610
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3210
3611
  }
3211
3612
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileFieldComponent, decorators: [{
3212
3613
  type: Component,
3213
3614
  args: [{
3615
+ changeDetection: ChangeDetectionStrategy.OnPush,
3214
3616
  selector: 'ngx-file-field',
3215
3617
  standalone: true,
3216
3618
  imports: [],
@@ -3299,7 +3701,8 @@ function builtInFieldTypes() {
3299
3701
  checkbox: CheckboxFieldComponent,
3300
3702
  boolean: BooleanFieldComponent,
3301
3703
  date: DateFieldComponent,
3302
- datetime: DateFieldComponent,
3704
+ datetime: DateTimeFieldComponent,
3705
+ time: TimeFieldComponent,
3303
3706
  monthYear: MonthYearFieldComponent,
3304
3707
  dropdown: DropdownFieldComponent,
3305
3708
  radio: RadioFieldComponent,
@@ -3440,7 +3843,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3440
3843
  */
3441
3844
  class DynamicFormComponent {
3442
3845
  // ─── Inputs ───────────────────────────────────────────────────────────────
3443
- config;
3846
+ /**
3847
+ * The schema to render.
3848
+ *
3849
+ * An accessor pair rather than a plain field: `ngOnChanges` normalises the option shape,
3850
+ * and that result is kept here instead of being written back over the input. Reassigning
3851
+ * an `@Input` works, but it mutates what the parent handed us — and a parent using OnPush
3852
+ * or signals would never observe the replacement anyway.
3853
+ *
3854
+ * Reads of `this.config` get the normalised copy once it exists, so every internal caller
3855
+ * is unaffected.
3856
+ */
3857
+ set config(value) {
3858
+ this.rawConfig = value;
3859
+ this.normalizedConfig = undefined; // re-normalised in ngOnChanges
3860
+ }
3861
+ get config() {
3862
+ return this.normalizedConfig ?? this.rawConfig;
3863
+ }
3864
+ rawConfig;
3865
+ normalizedConfig;
3444
3866
  rules;
3445
3867
  initialData;
3446
3868
  /** Session-original values for `VALUE_CHANGED` rules. Captured from the first build when omitted. */
@@ -3474,7 +3896,13 @@ class DynamicFormComponent {
3474
3896
  formReset = new EventEmitter();
3475
3897
  /** The visible tab changed. Lets a host track it without reaching into this component. */
3476
3898
  activeTabChange = new EventEmitter();
3899
+ /**
3900
+ * Emitted when a `beforeSave` hook aborted the save — by returning false or throwing.
3901
+ * `formSubmit` does not fire in that case.
3902
+ */
3903
+ saveRejected = new EventEmitter();
3477
3904
  // ─── Services ─────────────────────────────────────────────────────────────
3905
+ cdr = inject(ChangeDetectorRef);
3478
3906
  fb = inject(FormBuilder);
3479
3907
  validatorRegistry = inject(ValidatorRegistryService);
3480
3908
  hookRegistry = inject(HookRegistryService);
@@ -3493,9 +3921,14 @@ class DynamicFormComponent {
3493
3921
  sessionBaseline = signal({});
3494
3922
  /** Critical fields the user has explicitly unlocked for editing. */
3495
3923
  unlockedFields = signal(new Set());
3924
+ /** The active tab's field panel, focused after a tab switch. */
3925
+ formPanel;
3926
+ /** The hosted field components, so an external control-state change can reach them. */
3927
+ fieldHosts;
3496
3928
  // ─── Form ─────────────────────────────────────────────────────────────────
3497
3929
  form;
3498
3930
  valueSub;
3931
+ statusSub;
3499
3932
  selectionSub;
3500
3933
  /** Previous values, for detecting `patchOnTrue` false→true transitions. */
3501
3934
  previousValues = {};
@@ -3618,9 +4051,19 @@ class DynamicFormComponent {
3618
4051
  get hasRuleErrors() {
3619
4052
  return Object.keys(this.ruleValidationErrors).length > 0;
3620
4053
  }
4054
+ /**
4055
+ * True while an async validator is still running.
4056
+ *
4057
+ * Angular reports a control with an outstanding async check as `pending`, and `invalid` is
4058
+ * false until it settles — so without this a form could be submitted in the gap before a
4059
+ * uniqueness check came back.
4060
+ */
4061
+ get isValidating() {
4062
+ return this.form.pending;
4063
+ }
3621
4064
  /** Single source of truth for both the submit guard and the button's disabled state. */
3622
4065
  get submitBlocked() {
3623
- return this.form.invalid || this.hasRuleErrors;
4066
+ return this.form.invalid || this.form.pending || this.hasRuleErrors;
3624
4067
  }
3625
4068
  ngOnInit() {
3626
4069
  this.selectionSub = this.entityRefSelection.selection$.subscribe(({ fieldId, option }) => {
@@ -3631,12 +4074,15 @@ class DynamicFormComponent {
3631
4074
  if (changes['config'] || changes['userRoles']) {
3632
4075
  this.permissionsCache = null;
3633
4076
  }
3634
- if (changes['config'] && this.config) {
4077
+ if (changes['config'] && this.rawConfig) {
3635
4078
  // Configs arrive as plain JSON from storage or an API, where TypeScript cannot enforce
3636
4079
  // the option shape. Normalise here, at the library boundary, so everything downstream
3637
4080
  // can rely on an option being a LocalizedText. Returns the same object when it already
3638
4081
  // is, so a well-formed config costs nothing.
3639
- this.config = normalizeConfigOptions(this.config);
4082
+ // Kept in a field of our own rather than written back to the @Input. Reassigning an
4083
+ // input works, but it surprises a parent that holds the same object — and a parent
4084
+ // using OnPush or signals would not see the replacement anyway.
4085
+ this.normalizedConfig = normalizeConfigOptions(this.config);
3640
4086
  }
3641
4087
  if (changes['config'] || changes['initialData']) {
3642
4088
  this.buildForm();
@@ -3647,12 +4093,16 @@ class DynamicFormComponent {
3647
4093
  }
3648
4094
  ngOnDestroy() {
3649
4095
  this.valueSub?.unsubscribe();
4096
+ this.statusSub?.unsubscribe();
3650
4097
  this.selectionSub?.unsubscribe();
3651
4098
  }
3652
4099
  setActiveTab(tabId) {
3653
- if (this.activeTab() !== tabId)
4100
+ const changed = this.activeTab() !== tabId;
4101
+ if (changed)
3654
4102
  this.activeTabChange.emit(tabId);
3655
4103
  this.activeTab.set(tabId);
4104
+ if (changed)
4105
+ this.focusActivePanel();
3656
4106
  const parent = findTab(this.tabs, tabId);
3657
4107
  if (parent?.children?.length) {
3658
4108
  this.activeSubTab.set(parent.children[0].id);
@@ -3662,7 +4112,10 @@ class DynamicFormComponent {
3662
4112
  }
3663
4113
  }
3664
4114
  setActiveSubTab(subTabId) {
4115
+ const changed = this.activeSubTab() !== subTabId;
3665
4116
  this.activeSubTab.set(subTabId);
4117
+ if (changed)
4118
+ this.focusActivePanel();
3666
4119
  }
3667
4120
  getFieldSpan(field) {
3668
4121
  const span = field.colSpan ?? 12;
@@ -3748,6 +4201,12 @@ class DynamicFormComponent {
3748
4201
  const changes$ = this.changeDebounceMs > 0
3749
4202
  ? this.form.valueChanges.pipe(debounceTime(this.changeDebounceMs))
3750
4203
  : this.form.valueChanges;
4204
+ // Validity is not value: an async validator settling flips `pending` and then `invalid`
4205
+ // with no value change and no template event, so under OnPush the Save button would
4206
+ // never re-enable. submitBlocked reads form.pending and form.invalid, so this is what
4207
+ // keeps it honest.
4208
+ this.statusSub?.unsubscribe();
4209
+ this.statusSub = this.form.statusChanges.subscribe(() => this.cdr.markForCheck());
3751
4210
  this.valueSub = changes$.subscribe(() => {
3752
4211
  const flattened = this.flattenFormValues();
3753
4212
  this.formValues.set(flattened);
@@ -3770,7 +4229,8 @@ class DynamicFormComponent {
3770
4229
  }
3771
4230
  else {
3772
4231
  const validators = this.validatorRegistry.resolveFromConfig(field.validators);
3773
- group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators);
4232
+ const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
4233
+ group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
3774
4234
  }
3775
4235
  }
3776
4236
  /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
@@ -3801,6 +4261,16 @@ class DynamicFormComponent {
3801
4261
  group.patchValue(item);
3802
4262
  return group;
3803
4263
  }
4264
+ /**
4265
+ * Mark every control touched, then re-check the field components.
4266
+ *
4267
+ * They are OnPush and `touched` is flipped from outside their own template, so the second
4268
+ * half is what makes the error messages actually appear.
4269
+ */
4270
+ markAllTouched() {
4271
+ this.form.markAllAsTouched();
4272
+ this.fieldHosts?.forEach(host => host.refresh());
4273
+ }
3804
4274
  /**
3805
4275
  * Bring a record up to the config's `version` before it reaches the form.
3806
4276
  *
@@ -3970,6 +4440,10 @@ class DynamicFormComponent {
3970
4440
  const control = this.resolveTargetControl(autoPatch, targetId);
3971
4441
  control?.patchValue(value);
3972
4442
  }
4443
+ // Readonly targets interpolate `control.value` under OnPush. `detectChanges` on the
4444
+ // hosted component flushes that write immediately — `markForCheck` waits for a later
4445
+ // tick that a Playwright selection never schedules.
4446
+ this.fieldHosts?.forEach(host => host.refresh());
3973
4447
  }
3974
4448
  /**
3975
4449
  * `patchOnTrue` — when a boolean/checkbox field transitions to `true`,
@@ -4115,24 +4589,70 @@ class DynamicFormComponent {
4115
4589
  };
4116
4590
  return findInGroup(this.form);
4117
4591
  }
4592
+ /**
4593
+ * Move focus into the newly shown panel.
4594
+ *
4595
+ * Activating a tab replaces everything below it while focus stays on the tab button, so a
4596
+ * screen-reader or keyboard user is given no indication that the content changed and must
4597
+ * tab back through the strip to reach it. Deferred a frame because the panel's contents
4598
+ * are rendered by the change detection this call is part of.
4599
+ */
4600
+ focusActivePanel() {
4601
+ if (typeof requestAnimationFrame !== 'function')
4602
+ return;
4603
+ requestAnimationFrame(() => this.formPanel?.nativeElement?.focus?.());
4604
+ }
4605
+ /** Accessible name for the field panel — it is the tabpanel for the active tab. */
4606
+ get activeTabLabel() {
4607
+ const tab = this.activeSubTabConfig ?? this.activeTabConfig;
4608
+ return tab ? this.resolveTabLabel(tab) : '';
4609
+ }
4610
+ /**
4611
+ * Why a referenced field is flagged.
4612
+ *
4613
+ * `hasDrift` was written by the builder and read by nothing at runtime, so a field whose
4614
+ * source definition had changed since it was linked looked entirely normal to the person
4615
+ * filling the form in.
4616
+ */
4617
+ driftHint(field) {
4618
+ const source = field.referencedEntityKey
4619
+ ? `"${field.referencedEntityKey}"`
4620
+ : 'its source entity';
4621
+ return `This field is linked to ${source} and its definition there has changed since it was linked.`;
4622
+ }
4118
4623
  resolveTabLabel(tab) {
4119
4624
  return resolveLabel(tab.label, this.language);
4120
4625
  }
4121
4626
  async submit() {
4122
4627
  if (!this.canSubmit || this.submitBlocked) {
4123
- this.form.markAllAsTouched();
4628
+ this.markAllTouched();
4124
4629
  return;
4125
4630
  }
4126
4631
  const rawData = this.extractRecord();
4127
4632
  let processedData = rawData;
4128
- const hookKey = `${this.config?.entity}:beforeSave`;
4129
- if (this.hookRegistry.has(hookKey)) {
4130
- processedData = await this.hookRegistry.run(hookKey, processedData);
4131
- }
4132
4633
  this.isSaving.set(true);
4133
4634
  try {
4635
+ // beforeSave can now abort the save: returning false, or throwing, stops it. Previously
4636
+ // its return value simply replaced the payload and the submit proceeded regardless, so
4637
+ // a hook that wanted to veto — a server-side check, a confirmation — had no way to.
4638
+ const hookKey = `${this.config?.entity}:beforeSave`;
4639
+ if (this.hookRegistry.has(hookKey)) {
4640
+ const result = await this.hookRegistry.run(hookKey, processedData);
4641
+ if (result === false) {
4642
+ this.saveRejected.emit({ reason: 'beforeSave returned false' });
4643
+ return;
4644
+ }
4645
+ // Anything else is the payload to submit; `undefined` means "unchanged".
4646
+ if (result !== undefined && result !== true)
4647
+ processedData = result;
4648
+ }
4134
4649
  this.formSubmit.emit(processedData);
4135
4650
  }
4651
+ catch (err) {
4652
+ // A hook that throws aborts the save and reports why, rather than the error escaping
4653
+ // into an unhandled rejection with the form left looking as though it saved.
4654
+ this.saveRejected.emit({ reason: err instanceof Error ? err.message : String(err), error: err });
4655
+ }
4136
4656
  finally {
4137
4657
  this.isSaving.set(false);
4138
4658
  }
@@ -4148,11 +4668,11 @@ class DynamicFormComponent {
4148
4668
  this.formReset.emit();
4149
4669
  }
4150
4670
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4151
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", 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" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], 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 <!-- Module tab component OR Fields for active tab -->\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 >\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.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\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],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", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }] });
4671
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", 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],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", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4152
4672
  }
4153
4673
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, decorators: [{
4154
4674
  type: Component,
4155
- args: [{ 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 <!-- Module tab component OR Fields for active tab -->\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 >\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.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\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"] }]
4675
+ 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"] }]
4156
4676
  }], propDecorators: { config: [{
4157
4677
  type: Input
4158
4678
  }], rules: [{
@@ -4187,6 +4707,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
4187
4707
  type: Output
4188
4708
  }], activeTabChange: [{
4189
4709
  type: Output
4710
+ }], saveRejected: [{
4711
+ type: Output
4712
+ }], formPanel: [{
4713
+ type: ViewChild,
4714
+ args: ['formPanel']
4715
+ }], fieldHosts: [{
4716
+ type: ViewChildren,
4717
+ args: [DynamicFieldComponent]
4190
4718
  }], handleKeyboardEvent: [{
4191
4719
  type: HostListener,
4192
4720
  args: ['window:keydown', ['$event']]
@@ -4230,6 +4758,8 @@ class DynamicRecordFormComponent {
4230
4758
  sectionSave = new EventEmitter();
4231
4759
  dynamicFormComp;
4232
4760
  rulesEvaluation = inject(RulesEvaluationService);
4761
+ host = inject(ElementRef);
4762
+ injector = inject(Injector);
4233
4763
  currentData = signal({});
4234
4764
  originalBaseline = signal({});
4235
4765
  /**
@@ -4619,29 +5149,64 @@ class DynamicRecordFormComponent {
4619
5149
  formatFieldValue(field) {
4620
5150
  return formatDisplayValue(field.type, field.options, this.fieldValue(field.id), this.language);
4621
5151
  }
4622
- jumpToField(fieldId) {
4623
- // Find parent tab for field
5152
+ /**
5153
+ * Finds which tab — and, where the field lives one level down, which sub-tab — owns a
5154
+ * field. The walk used to check top-level `fields` only, so every field in a sub-tab was
5155
+ * simply not found and the jump did nothing at all.
5156
+ */
5157
+ locateField(fieldId) {
4624
5158
  for (const tab of this.config?.tabs || []) {
4625
- const hasField = (tab.fields || []).some(f => f.id === fieldId);
4626
- if (hasField) {
4627
- this.dynamicFormComp?.setActiveTab(tab.id);
4628
- setTimeout(() => {
4629
- const el = document.getElementById(`field-container-${fieldId}`);
4630
- if (el) {
4631
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
4632
- el.focus();
4633
- }
4634
- }, 50);
4635
- break;
5159
+ if ((tab.fields || []).some(f => f.id === fieldId))
5160
+ return { tabId: tab.id };
5161
+ for (const child of tab.children || []) {
5162
+ if ((child.fields || []).some(f => f.id === fieldId)) {
5163
+ return { tabId: tab.id, subTabId: child.id };
5164
+ }
4636
5165
  }
4637
5166
  }
5167
+ return null;
5168
+ }
5169
+ /**
5170
+ * Switches to the tab holding `fieldId`, then scrolls it into view and moves focus to it.
5171
+ *
5172
+ * The wait for the new tab to render used to be a 50 ms `setTimeout`, which was wrong three
5173
+ * ways: `document` is undefined on a server render, 50 ms is a guess that a large tab can
5174
+ * outrun, and nothing cancelled the timer if the component went away first.
5175
+ * `afterNextRender` fixes all three — it never runs on the server, it runs when the panel
5176
+ * has actually rendered rather than when a guess expires, and it is tied to the injector so
5177
+ * destroying the component cancels it. The query is scoped to this component's own element,
5178
+ * so the library no longer reaches for the global `document` at all.
5179
+ */
5180
+ jumpToField(fieldId) {
5181
+ const location = this.locateField(fieldId);
5182
+ if (!location)
5183
+ return;
5184
+ this.dynamicFormComp?.setActiveTab(location.tabId);
5185
+ // `setActiveTab` resets to a tab's first child, so the sub-tab has to be selected after
5186
+ // it, not before. Without this the walk found sub-tab fields and then rendered the wrong
5187
+ // panel, so the element the callback below looks for never existed.
5188
+ if (location.subTabId)
5189
+ this.dynamicFormComp?.setActiveSubTab(location.subTabId);
5190
+ afterNextRender(() => {
5191
+ // The field id comes from config, so it never goes into a selector string: no
5192
+ // escaping to get wrong, and no need for `CSS.escape`, which jsdom and older
5193
+ // browsers do not provide and whose absence throws silently inside a render hook.
5194
+ const wanted = `field-container-${fieldId}`;
5195
+ const el = Array.from(this.host.nativeElement.querySelectorAll('[id^="field-container-"]')).find(slot => slot.id === wanted);
5196
+ if (!el)
5197
+ return;
5198
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
5199
+ // The slot carries tabindex="-1" so this actually moves focus. Without it the jump
5200
+ // scrolled the field into view and left focus behind on the link.
5201
+ el.focus();
5202
+ }, { injector: this.injector });
4638
5203
  }
4639
5204
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4640
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", 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],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"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
5205
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", 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],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 });
4641
5206
  }
4642
5207
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
4643
5208
  type: Component,
4644
- args: [{ 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"] }]
5209
+ 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"] }]
4645
5210
  }], propDecorators: { config: [{
4646
5211
  type: Input
4647
5212
  }], rules: [{
@@ -4683,5 +5248,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
4683
5248
  * Generated bundle index. Do not edit.
4684
5249
  */
4685
5250
 
4686
- export { ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DateFieldComponent, 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, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, UPLOAD_HANDLER, VALIDATOR_REGISTRY, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
5251
+ 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, MASKED_ROLES, 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 };
4687
5252
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map