@ng-forge/dynamic-forms 0.9.0-next.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +1 -0
  2. package/fesm2022/{ng-forge-dynamic-forms-array-field.component-CltYGHg9.mjs → ng-forge-dynamic-forms-array-field.component-Tw49K5kz.mjs} +171 -73
  3. package/fesm2022/ng-forge-dynamic-forms-array-field.component-Tw49K5kz.mjs.map +1 -0
  4. package/fesm2022/{ng-forge-dynamic-forms-container-field.component-D4Fpr2ri.mjs → ng-forge-dynamic-forms-container-field.component-CrOIZEL_.mjs} +12 -8
  5. package/fesm2022/ng-forge-dynamic-forms-container-field.component-CrOIZEL_.mjs.map +1 -0
  6. package/fesm2022/{ng-forge-dynamic-forms-container-utils-BTiqZ8Np.mjs → ng-forge-dynamic-forms-container-utils-Bx1A5tg-.mjs} +2 -2
  7. package/fesm2022/{ng-forge-dynamic-forms-container-utils-BTiqZ8Np.mjs.map → ng-forge-dynamic-forms-container-utils-Bx1A5tg-.mjs.map} +1 -1
  8. package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-mdfySFjX.mjs → ng-forge-dynamic-forms-css-wrapper.component-Q2g56KUM.mjs} +5 -5
  9. package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-Q2g56KUM.mjs.map +1 -0
  10. package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-Bt3kvlOY.mjs → ng-forge-dynamic-forms-emit-initialization-9l5gPNQc.mjs} +2 -2
  11. package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-Bt3kvlOY.mjs.map → ng-forge-dynamic-forms-emit-initialization-9l5gPNQc.mjs.map} +1 -1
  12. package/fesm2022/{ng-forge-dynamic-forms-group-field.component-BxswrhOc.mjs → ng-forge-dynamic-forms-group-field.component-DqnhddRH.mjs} +36 -12
  13. package/fesm2022/ng-forge-dynamic-forms-group-field.component-DqnhddRH.mjs.map +1 -0
  14. package/fesm2022/ng-forge-dynamic-forms-integration.mjs +936 -54
  15. package/fesm2022/ng-forge-dynamic-forms-integration.mjs.map +1 -1
  16. package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-QdV2vcCx.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-Be1Lk0qR.mjs} +2423 -466
  17. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-Be1Lk0qR.mjs.map +1 -0
  18. package/fesm2022/ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs +114 -0
  19. package/fesm2022/ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs.map +1 -0
  20. package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs +34 -0
  21. package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs.map +1 -0
  22. package/fesm2022/{ng-forge-dynamic-forms-text-field.component-Cf1mEKVW.mjs → ng-forge-dynamic-forms-text-field.component-C14mlROS.mjs} +5 -5
  23. package/fesm2022/{ng-forge-dynamic-forms-text-field.component-Cf1mEKVW.mjs.map → ng-forge-dynamic-forms-text-field.component-C14mlROS.mjs.map} +1 -1
  24. package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
  25. package/package.json +2 -2
  26. package/types/ng-forge-dynamic-forms-integration.d.ts +587 -11
  27. package/types/ng-forge-dynamic-forms.d.ts +3841 -2428
  28. package/fesm2022/ng-forge-dynamic-forms-array-field.component-CltYGHg9.mjs.map +0 -1
  29. package/fesm2022/ng-forge-dynamic-forms-container-field.component-D4Fpr2ri.mjs.map +0 -1
  30. package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-mdfySFjX.mjs.map +0 -1
  31. package/fesm2022/ng-forge-dynamic-forms-group-field.component-BxswrhOc.mjs.map +0 -1
  32. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-QdV2vcCx.mjs.map +0 -1
  33. package/fesm2022/ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs +0 -110
  34. package/fesm2022/ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs.map +0 -1
  35. package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-BIvEUoia.mjs +0 -34
  36. package/fesm2022/ng-forge-dynamic-forms-row-wrapper.component-BIvEUoia.mjs.map +0 -1
@@ -1,13 +1,14 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, reflectComponentType, Injector, inject, EnvironmentInjector, DestroyRef, computed, input, ViewContainerRef, signal, Directive, Injectable, isSignal, untracked, linkedSignal, ChangeDetectionStrategy, Component, resourceFromSnapshots, runInInjectionContext, afterNextRender, isWritableSignal, model, makeEnvironmentProviders, Pipe } from '@angular/core';
2
+ import { InjectionToken, inject, reflectComponentType, Injector, EnvironmentInjector, DestroyRef, computed, signal, input, ViewContainerRef, Directive, Injectable, isSignal, untracked, linkedSignal, ChangeDetectionStrategy, Component, resourceFromSnapshots, runInInjectionContext, afterNextRender, isWritableSignal, TemplateRef, model, contentChildren, makeEnvironmentProviders, Pipe } from '@angular/core';
3
3
  import { explicitEffect } from 'ngxtension/explicit-effect';
4
- import { of, forkJoin, from, catchError, map, switchMap, isObservable, firstValueFrom, exhaustMap, EMPTY, Subject, filter, TimeoutError, throwError, defer, combineLatest, pipe, debounceTime, distinctUntilChanged, tap, startWith, scan as scan$1, Observable, concatMap, take as take$1, pairwise, takeUntil, combineLatestWith, auditTime, scheduled, queueScheduler, merge, timer } from 'rxjs';
5
4
  import { takeUntilDestroyed, toObservable, toSignal, rxResource, outputFromObservable } from '@angular/core/rxjs-interop';
5
+ import { of, forkJoin, from, catchError, map, switchMap, isObservable, firstValueFrom, exhaustMap, EMPTY, Subject, filter, TimeoutError, throwError, defer, combineLatest, pipe, debounceTime, distinctUntilChanged, tap, startWith, scan as scan$1, Observable, concatMap, take as take$1, pairwise, takeUntil, combineLatestWith, auditTime, scheduled, queueScheduler, merge, timer } from 'rxjs';
6
6
  import { submit, email, min, max, minLength, maxLength, pattern, required, validate, validateAsync, validateHttp, disabled, readonly, hidden, applyEach, applyWhenValue, applyWhen, apply, schema, validateStandardSchema, validateTree, form, provideSignalFormsConfig } from '@angular/forms/signals';
7
7
  import { scan, map as map$1, filter as filter$1, take, switchMap as switchMap$1, timeout, catchError as catchError$1, shareReplay } from 'rxjs/operators';
8
8
  import { HttpClient } from '@angular/common/http';
9
9
  import { isStandardSchemaMarker } from '@ng-forge/dynamic-forms/schema';
10
10
  import { NG_STATUS_CLASSES } from '@angular/forms/signals/compat';
11
+ import { NgComponentOutlet, AsyncPipe, NgTemplateOutlet } from '@angular/common';
11
12
 
12
13
  /**
13
14
  * Type guard for WrapperTypeDefinition.
@@ -97,28 +98,39 @@ const DEFAULT_WRAPPERS = new InjectionToken('DEFAULT_WRAPPERS');
97
98
  *
98
99
  * @example
99
100
  * ```typescript
100
- * // In a mapper function
101
+ * // In a mapper function — prefer the helper for required access (descriptive error).
101
102
  * export function mapValueField(fieldDef: BaseValueField<any, any>): Binding[] {
102
- * const context = inject(FIELD_SIGNAL_CONTEXT);
103
+ * const context = injectFieldSignalContext();
103
104
  * const form = context.form();
104
105
  * // ... use context
105
106
  * }
106
107
  *
107
- * // In a component
108
- * export class MyFieldComponent {
109
- * private context = inject(FIELD_SIGNAL_CONTEXT);
108
+ * // Optional access — token directly, returns null if not provided.
109
+ * const ctx = inject(FIELD_SIGNAL_CONTEXT, { optional: true });
110
+ * if (ctx) {
111
+ * // ...
110
112
  * }
111
113
  * ```
112
114
  */
113
- const FIELD_SIGNAL_CONTEXT = new InjectionToken('FIELD_SIGNAL_CONTEXT', {
114
- providedIn: null, // Not provided at root - must be provided by DynamicForm
115
- factory: () => {
116
- throw new DynamicFormError('FIELD_SIGNAL_CONTEXT was not provided. ' +
117
- 'This token must be provided by DynamicFormComponent or a container field component. ' +
118
- 'If you are calling a mapper function directly, ensure it runs within runInInjectionContext() ' +
119
- 'with an injector that provides FIELD_SIGNAL_CONTEXT.');
120
- },
121
- });
115
+ const FIELD_SIGNAL_CONTEXT = new InjectionToken('FIELD_SIGNAL_CONTEXT');
116
+ /**
117
+ * Throwing accessor for {@link FIELD_SIGNAL_CONTEXT}.
118
+ *
119
+ * Use when the consumer requires the context — group/array containers and
120
+ * mappers that depend on form structure. Throws a descriptive
121
+ * `DynamicFormError` instead of Angular's generic `NullInjectorError` when
122
+ * the token isn't provided.
123
+ *
124
+ * Consumers that may legitimately run outside a `DynamicForm` should keep
125
+ * using `inject(FIELD_SIGNAL_CONTEXT, { optional: true })`.
126
+ */
127
+ function injectFieldSignalContext() {
128
+ const ctx = inject(FIELD_SIGNAL_CONTEXT, { optional: true });
129
+ if (!ctx) {
130
+ throw new DynamicFormError('FIELD_SIGNAL_CONTEXT is not provided. This consumer must run inside a <df-dynamic-form> component tree.');
131
+ }
132
+ return ctx;
133
+ }
122
134
  /**
123
135
  * Injection token for providing array context metadata to mappers and components.
124
136
  *
@@ -139,6 +151,32 @@ const FIELD_SIGNAL_CONTEXT = new InjectionToken('FIELD_SIGNAL_CONTEXT', {
139
151
  * ```
140
152
  */
141
153
  const ARRAY_CONTEXT = new InjectionToken('ARRAY_CONTEXT');
154
+ /**
155
+ * Injection token for providing group ancestry to mappers and components.
156
+ *
157
+ * Re-provided in two places:
158
+ * 1. `GroupFieldComponent` provides a context whose `groupPath()` is the
159
+ * parent path extended by its own key (e.g. `'address'` or `'user.address'`).
160
+ * 2. `createArrayItemInjector` provides a sentinel with an empty `groupPath`,
161
+ * resetting the chain at array boundaries — descendants of an array item
162
+ * that re-enter a group compose paths INSIDE the array item, matching the
163
+ * property-derivation collector's keying.
164
+ *
165
+ * `mapFieldToInputs` reads this to scope DOM IDs and override-store keys so
166
+ * the same leaf key can appear in different groups without colliding (#401).
167
+ *
168
+ * Inject with `{ optional: true }` because top-level fields with no group
169
+ * ancestor (and no enclosing array) won't have the token provided. The token
170
+ * IS present for fields inside an array even when no inner group exists — the
171
+ * array-boundary sentinel makes injection succeed with an empty `groupPath`.
172
+ */
173
+ // No factory: GROUP_CONTEXT is optional-by-design — `GroupFieldComponent` and
174
+ // `createArrayItemInjector` re-provide it where it makes sense, and consumers
175
+ // inject with `{ optional: true }` to handle the top-level case. Mirrors the
176
+ // ARRAY_CONTEXT pattern. Adding a throwing factory here fires even when
177
+ // injected optionally (Angular still evaluates the tree-shakable factory
178
+ // before honoring `optional`), so non-group fields would crash on every render.
179
+ const GROUP_CONTEXT = new InjectionToken('GROUP_CONTEXT');
142
180
  /**
143
181
  * Injection token for form-level default props.
144
182
  *
@@ -226,65 +264,57 @@ const FORM_OPTIONS = new InjectionToken('FORM_OPTIONS');
226
264
  * and inherited by all children via Angular's hierarchical injector.
227
265
  */
228
266
  const EXTERNAL_DATA = new InjectionToken('EXTERNAL_DATA');
267
+
229
268
  /**
230
- * Injection token for array-level template registry.
231
- *
232
- * This registry tracks which template was used to create each array item,
233
- * keyed by the item's unique ID. This is essential for "recreate" operations
234
- * (e.g., after removing items from the middle) where we need to re-resolve
235
- * items using their original templates, not a fallback.
236
- *
237
- * The token is provided by ArrayFieldComponent at its level and shared
238
- * across all items in that array. Each nested array gets its own registry.
239
- *
240
- * @example
241
- * ```typescript
242
- * // In ArrayFieldComponent
243
- * providers: [
244
- * { provide: ARRAY_TEMPLATE_REGISTRY, useValue: new Map() }
245
- * ]
246
- *
247
- * // When adding items
248
- * const registry = inject(ARRAY_TEMPLATE_REGISTRY);
249
- * registry.set(itemId, templates);
269
+ * Injection token for the dynamic forms logger.
250
270
  *
251
- * // During recreate, look up original template
252
- * const originalTemplate = registry.get(existingItemId);
253
- * ```
271
+ * Provided by provideDynamicForm() with ConsoleLogger as default.
272
+ * Override with withLoggerConfig(false) to disable logging.
254
273
  */
255
- const ARRAY_TEMPLATE_REGISTRY = new InjectionToken('ARRAY_TEMPLATE_REGISTRY');
274
+ const DynamicFormLogger = new InjectionToken('DynamicFormLogger');
275
+
256
276
  /**
257
- * Injection token for array-level item ID generator.
258
- *
259
- * Provides a function that generates unique IDs for array items. Each array
260
- * component instance gets its own generator (via useFactory), ensuring:
261
- * - SSR hydration compatibility (server and client generate same IDs for same array)
262
- * - No global state pollution between form instances
263
- * - Deterministic IDs within each array's lifecycle
264
- *
265
- * The token is provided by ArrayFieldComponent at its level with a factory
266
- * that creates a fresh counter for each array instance.
267
- *
268
- * @example
269
- * ```typescript
270
- * // In ArrayFieldComponent
271
- * providers: [
272
- * { provide: ARRAY_ITEM_ID_GENERATOR, useFactory: createArrayItemIdGenerator }
273
- * ]
274
- *
275
- * // Usage
276
- * const generateId = inject(ARRAY_ITEM_ID_GENERATOR);
277
- * const itemId = generateId(); // 'item-0', 'item-1', etc.
278
- * ```
277
+ * Shared empty chain — returned when no wrappers apply so callers get a
278
+ * stable reference. Freezing makes accidental mutation observable.
279
279
  */
280
- const ARRAY_ITEM_ID_GENERATOR = new InjectionToken('ARRAY_ITEM_ID_GENERATOR');
280
+ const EMPTY_WRAPPERS = Object.freeze([]);
281
281
  /**
282
- * Factory function that creates a new array item ID generator.
283
- * Each invocation creates an independent counter starting at 0.
282
+ * Element-wise identity (`===`) comparator for wrapper chains. Used as the
283
+ * `equal` option on signal memoisation so reconciled `FieldDef`s with the
284
+ * same chain don't churn downstream — relies on `WrapperConfig` objects
285
+ * being stable across ticks, which holds for configs declared in
286
+ * `FormConfig` / `createWrappers(...)`.
284
287
  */
285
- function createArrayItemIdGenerator() {
286
- let counter = 0;
287
- return () => `item-${counter++}`;
288
+ function isSameWrapperChain(a, b) {
289
+ if (a === b)
290
+ return true;
291
+ if (a.length !== b.length)
292
+ return false;
293
+ for (let i = 0; i < a.length; i++) {
294
+ if (a[i] !== b[i])
295
+ return false;
296
+ }
297
+ return true;
298
+ }
299
+ /**
300
+ * Resolves the wrapper chain for a field.
301
+ *
302
+ * Merge order (outermost → innermost): auto-associations, form defaults,
303
+ * field-level wrappers. `wrappers: null` skips all three; `skipAutoWrappers` /
304
+ * `skipDefaultWrappers` on the field opt out of individual layers. Fresh
305
+ * array per call — downstream memoisation is on the consuming computed.
306
+ */
307
+ function resolveWrappers(field, defaultWrappers, autoAssociations) {
308
+ if (field.wrappers === null) {
309
+ return EMPTY_WRAPPERS;
310
+ }
311
+ const autoWrappers = field.skipAutoWrappers ? EMPTY_WRAPPERS : (autoAssociations.get(field.type) ?? EMPTY_WRAPPERS);
312
+ const defaults = field.skipDefaultWrappers ? EMPTY_WRAPPERS : (defaultWrappers ?? EMPTY_WRAPPERS);
313
+ const fieldLevel = field.wrappers ?? EMPTY_WRAPPERS;
314
+ if (autoWrappers.length === 0 && defaults.length === 0 && fieldLevel.length === 0) {
315
+ return EMPTY_WRAPPERS;
316
+ }
317
+ return [...autoWrappers, ...defaults, ...fieldLevel];
288
318
  }
289
319
 
290
320
  /**
@@ -324,8 +354,7 @@ function getDeclaredInputs(componentType) {
324
354
  * avoiding the reflection scan on each call is meaningful under heavy typing.
325
355
  */
326
356
  function setInputIfDeclared(ref, inputName, value) {
327
- const declared = getDeclaredInputs(ref.componentType);
328
- if (declared.has(inputName)) {
357
+ if (getDeclaredInputs(ref.componentType).has(inputName)) {
329
358
  ref.setInput(inputName, value);
330
359
  }
331
360
  }
@@ -482,58 +511,6 @@ class WrapperAwareInjector extends Injector {
482
511
  }
483
512
  }
484
513
 
485
- /**
486
- * Injection token for the dynamic forms logger.
487
- *
488
- * Provided by provideDynamicForm() with ConsoleLogger as default.
489
- * Override with withLoggerConfig(false) to disable logging.
490
- */
491
- const DynamicFormLogger = new InjectionToken('DynamicFormLogger');
492
-
493
- /**
494
- * Shared empty chain — returned when no wrappers apply so callers get a
495
- * stable reference. Freezing makes accidental mutation observable.
496
- */
497
- const EMPTY_WRAPPERS = Object.freeze([]);
498
- /**
499
- * Element-wise identity (`===`) comparator for wrapper chains. Used as the
500
- * `equal` option on signal memoisation so reconciled `FieldDef`s with the
501
- * same chain don't churn downstream — relies on `WrapperConfig` objects
502
- * being stable across ticks, which holds for configs declared in
503
- * `FormConfig` / `createWrappers(...)`.
504
- */
505
- function isSameWrapperChain(a, b) {
506
- if (a === b)
507
- return true;
508
- if (a.length !== b.length)
509
- return false;
510
- for (let i = 0; i < a.length; i++) {
511
- if (a[i] !== b[i])
512
- return false;
513
- }
514
- return true;
515
- }
516
- /**
517
- * Resolves the wrapper chain for a field.
518
- *
519
- * Merge order (outermost → innermost): auto-associations, form defaults,
520
- * field-level wrappers. `wrappers: null` skips all three; `skipAutoWrappers` /
521
- * `skipDefaultWrappers` on the field opt out of individual layers. Fresh
522
- * array per call — downstream memoisation is on the consuming computed.
523
- */
524
- function resolveWrappers(field, defaultWrappers, autoAssociations) {
525
- if (field.wrappers === null) {
526
- return EMPTY_WRAPPERS;
527
- }
528
- const autoWrappers = field.skipAutoWrappers ? EMPTY_WRAPPERS : (autoAssociations.get(field.type) ?? EMPTY_WRAPPERS);
529
- const defaults = field.skipDefaultWrappers ? EMPTY_WRAPPERS : (defaultWrappers ?? EMPTY_WRAPPERS);
530
- const fieldLevel = field.wrappers ?? EMPTY_WRAPPERS;
531
- if (autoWrappers.length === 0 && defaults.length === 0 && fieldLevel.length === 0) {
532
- return EMPTY_WRAPPERS;
533
- }
534
- return [...autoWrappers, ...defaults, ...fieldLevel];
535
- }
536
-
537
514
  /**
538
515
  * Owns the wrapper-chain lifecycle for a field or container: async component
539
516
  * loading, switchMap-based cancellation of stale loads, flicker-tolerant
@@ -545,6 +522,9 @@ function createWrapperChainController(opts) {
545
522
  const state = createStateSignal(opts);
546
523
  const mounted = { value: null };
547
524
  let refs = [];
525
+ // INVARIANT: toObservable schedules emissions as microtasks, so `renderInnermost`
526
+ // runs outside any parent CD pass. Don't swap for sync signal wiring without
527
+ // restoring df-field-outlet's detectChanges — NG0950 would re-surface.
548
528
  buildEmissionStream(state, deps)
549
529
  .pipe(takeUntilDestroyed(deps.destroyRef))
550
530
  .subscribe((emission) => {
@@ -691,6 +671,20 @@ function toReadonlyFieldTree(field) {
691
671
  errors: state.errors,
692
672
  };
693
673
  }
674
+ /**
675
+ * Write a value into a FieldTree's underlying signal. Signal Forms stores
676
+ * `FieldState.value` as a `WritableSignal`; this helper centralises the cast
677
+ * and warns if that contract ever changes (vs a silent no-op).
678
+ */
679
+ function writeToFieldValue(value, next, logger) {
680
+ const candidate = value;
681
+ if (typeof candidate.set !== 'function') {
682
+ logger?.warn('writeToFieldValue: FieldState.value is not a WritableSignal — Signal Forms contract changed.');
683
+ return false;
684
+ }
685
+ candidate.set(next);
686
+ return true;
687
+ }
694
688
  /**
695
689
  * DI-scoped cache for `ReadonlyFieldTree` views, keyed on the source FieldTree
696
690
  * identity. A fresh WeakMap per root injector keeps renders isolated under SSR —
@@ -721,6 +715,209 @@ function toReadonlyFieldTreeCached(cache, field) {
721
715
  return view;
722
716
  }
723
717
 
718
+ /**
719
+ * Convert a field's raw mapper outputs into the {@link WrapperFieldInputs}
720
+ * bag consumed by wrappers and addons.
721
+ *
722
+ * The transformation hides write access on the underlying `FieldTree` —
723
+ * the bag's `field` slot is a {@link ReadonlyFieldTree} view. The raw
724
+ * `FieldTree` is still pushed to the innermost field component (which
725
+ * legitimately needs to write); intermediaries (wrappers, addons) are
726
+ * read-only by contract.
727
+ *
728
+ * Pure, dependency-injection-free — caller supplies the cache so the same
729
+ * `ReadonlyFieldTree` view is returned for the same source `FieldTree`
730
+ * across renders. Each `<df-dynamic-form>` instance scopes its own cache
731
+ * (SSR-safe).
732
+ *
733
+ * @param rawInputs Mapper outputs (`{ key, label, props, field?: FieldTree, ... }`).
734
+ * @param cache `WeakMap<FieldTree, ReadonlyFieldTree>` provided via
735
+ * {@link READONLY_FIELD_TREE_CACHE}.
736
+ * @param fieldType Optional field-type discriminant. Injected here (rather
737
+ * than via the field mapper) because `type` is the
738
+ * registry key, not a component input — but addons and
739
+ * wrappers legitimately need it.
740
+ */
741
+ function buildFieldInputs(rawInputs, cache, fieldType) {
742
+ const fieldTreeCandidate = rawInputs['field'];
743
+ // FieldTree is callable: `(): FieldState`. Anything else (undefined,
744
+ // primitive) means the field is rendered before the form is bound.
745
+ const hasFieldTree = fieldTreeCandidate !== undefined && typeof fieldTreeCandidate === 'function';
746
+ const readonlyField = hasFieldTree ? toReadonlyFieldTreeCached(cache, fieldTreeCandidate) : undefined;
747
+ // Lazy writer keyed on the raw FieldTree. Addons (e.g., `prime-button`
748
+ // presets) call this to mutate the host field. `writeToFieldValue`
749
+ // centralises the `Signal → WritableSignal` cast and warns at runtime
750
+ // if Signal Forms ever changes the contract.
751
+ const setValue = hasFieldTree
752
+ ? (next) => writeToFieldValue(fieldTreeCandidate().value, next)
753
+ : undefined;
754
+ // Shallow spread — relies on the mapper contract (see WrapperFieldInputs)
755
+ // that rawInputs are emitted as fresh snapshots, not mutated in place.
756
+ return {
757
+ ...rawInputs,
758
+ type: fieldType,
759
+ field: readonlyField,
760
+ setValue,
761
+ };
762
+ }
763
+
764
+ const EMPTY_STATE = Object.freeze({ phase: 'empty' });
765
+ /**
766
+ * Encapsulates the imperative `ViewContainerRef` / `ComponentRef` lifecycle
767
+ * for the innermost field component. Pulled out of `DfFieldOutlet` so the
768
+ * directive itself stays declarative — signals + constructor wiring — while
769
+ * the unavoidable mutable bookkeeping (Angular's view container API is
770
+ * imperative) lives in one place behind a small, focused surface.
771
+ *
772
+ * State is held in a single `signal<FieldSlotState>` discriminated union;
773
+ * every mutation is an explicit `state.set(...)` transition. No nullable
774
+ * instance fields, no `undefined`-initialized refs — each phase carries
775
+ * exactly the data it owns.
776
+ *
777
+ * Responsibilities:
778
+ * - Create the component (or reuse the same-class instance across a rebuild,
779
+ * preserving its DOM state, focus, and caret).
780
+ * - Push mapper inputs via `setInput`, dedup'd per-key so dev-mode NG0303
781
+ * warnings for undeclared mapper keys don't replay on every emission.
782
+ * - Detach cleanly before the outer VCR clears, so same-class reuse can
783
+ * re-insert the preserved `hostView`.
784
+ * - Destroy on directive teardown when the host view is still detached.
785
+ */
786
+ class FieldComponentSlot {
787
+ state = signal(EMPTY_STATE, ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
788
+ /**
789
+ * Per-key cache for `pushInputs`. Lives outside the lifecycle state machine on
790
+ * purpose — it's a `setInput`-call optimization that changes on every keystroke,
791
+ * not a phase transition. Keeping it here means the `state` signal only emits
792
+ * on real `empty ⇄ mounted ⇄ detached` transitions (≈ once per
793
+ * mount/destroy cycle), not once per input emission.
794
+ */
795
+ lastInputs = null;
796
+ /** Read-only view of the lifecycle phase. Useful for tests and assertions. */
797
+ phase = () => this.state().phase;
798
+ /** Read-only view of the full state. Exposed so tests can assert ref stability. */
799
+ snapshot = () => this.state();
800
+ /**
801
+ * Mount the field component into `slot`. Reuses the existing `ComponentRef`
802
+ * when its component type matches (preserving DOM/focus/caret across a
803
+ * wrapper-chain rebuild); otherwise discards the old ref and creates a fresh
804
+ * one.
805
+ */
806
+ mountOrReuse(slot, component, fieldInjector, environmentInjector, rawInputs, fieldInputs) {
807
+ const current = this.state();
808
+ if (current.phase !== 'empty' && current.ref.componentType === component) {
809
+ slot.insert(current.ref.hostView);
810
+ this.lastInputs = null;
811
+ this.state.set(Object.freeze({ phase: 'mounted', ref: current.ref, slot }));
812
+ this.pushInputs(rawInputs, fieldInputs);
813
+ if (current.phase === 'detached')
814
+ this.replayFocus(current.focusSnapshot);
815
+ return;
816
+ }
817
+ // Different component class — destroy the prior ref (if any) and create
818
+ // a fresh one. The `empty` transition before `createComponent` ensures a
819
+ // throw inside the factory doesn't leave a dangling ref the next mount
820
+ // would try to re-insert.
821
+ if (current.phase !== 'empty')
822
+ current.ref.destroy();
823
+ this.lastInputs = null;
824
+ this.state.set(EMPTY_STATE);
825
+ const ref = slot.createComponent(component, {
826
+ environmentInjector,
827
+ injector: createWrapperAwareInjector(fieldInjector, slot.injector),
828
+ });
829
+ this.state.set(Object.freeze({ phase: 'mounted', ref, slot }));
830
+ this.pushInputs(rawInputs, fieldInputs);
831
+ }
832
+ /**
833
+ * Push mapper inputs onto the mounted component. Per-key reference compare
834
+ * skips `setInput` for unchanged values — necessary in dev mode where
835
+ * `setInput` on unknown keys logs NG0303 per call (Angular 21 is lenient
836
+ * at runtime but warns); without the cache, every emission would re-warn
837
+ * for every undeclared mapper key.
838
+ *
839
+ * Updates the `lastInputs` cache (a plain field, not part of `state`) so the
840
+ * `state` signal doesn't churn its reference on every keystroke.
841
+ *
842
+ * `reflectComponentType` does NOT include hostDirective-forwarded inputs
843
+ * (angular/angular#49734), so a declared-input filter here would skip every
844
+ * value flowing through `NgForgeFieldShell` / `NgForgeField` /
845
+ * `NgForgeAction`. Mapper-as-contract is enforced by the `*_INPUTS`
846
+ * lockstep type assertions, not at runtime here.
847
+ */
848
+ pushInputs(rawInputs, fieldInputs) {
849
+ const current = this.state();
850
+ if (current.phase !== 'mounted')
851
+ return;
852
+ const prev = this.lastInputs;
853
+ if (prev === rawInputs)
854
+ return;
855
+ for (const [key, value] of Object.entries(rawInputs)) {
856
+ if (prev && prev[key] === value)
857
+ continue;
858
+ current.ref.setInput(key, value);
859
+ }
860
+ // Side-channel forwarding for the `fieldInputs` bag — gated on
861
+ // `setInputIfDeclared` so leaf fields that don't declare it (most
862
+ // non-addon-aware components) skip silently rather than triggering
863
+ // NG0303 in dev. Pushed unconditionally on every emission since the bag
864
+ // changes identity when `rawInputs` does.
865
+ if (fieldInputs !== undefined) {
866
+ setInputIfDeclared(current.ref, 'fieldInputs', fieldInputs);
867
+ }
868
+ this.lastInputs = rawInputs;
869
+ }
870
+ /**
871
+ * Detach the host view from its current slot, capturing focus + caret so the
872
+ * next `mountOrReuse()` can replay them after re-inserting into the new
873
+ * slot. A stranded `mounted` state with a bad slot index (re-entrancy during
874
+ * a partial rebuild) is treated like `empty` to avoid leaks.
875
+ */
876
+ detach() {
877
+ const current = this.state();
878
+ if (current.phase !== 'mounted')
879
+ return;
880
+ const focusSnapshot = this.captureFocus(current.ref);
881
+ const idx = current.slot.indexOf(current.ref.hostView);
882
+ if (idx >= 0)
883
+ current.slot.detach(idx);
884
+ this.lastInputs = null;
885
+ this.state.set(Object.freeze({ phase: 'detached', ref: current.ref, focusSnapshot }));
886
+ }
887
+ /**
888
+ * Called on directive teardown. Only destroys when the host view is still
889
+ * detached — otherwise the outer `vcr.clear()` cascade has already destroyed
890
+ * it (calling `.destroy()` again would throw).
891
+ */
892
+ destroyOnTeardown() {
893
+ const current = this.state();
894
+ if (current.phase === 'detached')
895
+ current.ref.destroy();
896
+ this.lastInputs = null;
897
+ this.state.set(EMPTY_STATE);
898
+ }
899
+ captureFocus(ref) {
900
+ const hostEl = ref.location.nativeElement;
901
+ const active = document.activeElement;
902
+ if (!(active instanceof HTMLElement) || !hostEl.contains(active))
903
+ return null;
904
+ const sel = active;
905
+ return {
906
+ element: active,
907
+ selectionStart: typeof sel.selectionStart === 'number' ? sel.selectionStart : null,
908
+ selectionEnd: typeof sel.selectionEnd === 'number' ? sel.selectionEnd : null,
909
+ };
910
+ }
911
+ replayFocus(snap) {
912
+ if (!snap || !snap.element.isConnected)
913
+ return;
914
+ snap.element.focus();
915
+ if (snap.selectionStart !== null && snap.selectionEnd !== null && 'setSelectionRange' in snap.element) {
916
+ snap.element.setSelectionRange(snap.selectionStart, snap.selectionEnd);
917
+ }
918
+ }
919
+ }
920
+
724
921
  /**
725
922
  * Structural directive that renders a `ResolvedField` with its effective
726
923
  * wrapper chain.
@@ -741,12 +938,16 @@ function toReadonlyFieldTreeCached(cache, field) {
741
938
  * includes the field's mapper outputs and a `ReadonlyFieldTree` view of its
742
939
  * form state.
743
940
  *
744
- * Rendering is gated by `field.renderReady()` — the directive waits until
745
- * the mapper produces the required inputs before instantiating the component.
746
- * Once rendered, a transient `renderReady → false` does *not* tear the chain
747
- * down; the controller keeps the mounted components alive and ignores the
748
- * flicker. Only a structural change (wrappers or component class) triggers
749
- * a rebuild.
941
+ * Rendering is gated by `field.renderReady() && !field.hidden()` — the
942
+ * directive waits until the mapper produces the required inputs AND the
943
+ * field isn't hidden before instantiating the component. Once rendered, a
944
+ * transient `renderReady → false` does *not* tear the chain down; the
945
+ * controller keeps the mounted components alive and ignores the flicker.
946
+ * Only a structural change (wrappers or component class) triggers a rebuild.
947
+ *
948
+ * Imperative `ComponentRef` / `ViewContainerRef` lifecycle is encapsulated
949
+ * in `FieldComponentSlot` — the directive itself is signal definitions
950
+ * plus three method calls (`mountOrReuse`, `detach`, `destroyOnTeardown`).
750
951
  *
751
952
  * @example
752
953
  * ```html
@@ -766,25 +967,16 @@ class DfFieldOutlet {
766
967
  wrapperAutoAssociations = inject(WRAPPER_AUTO_ASSOCIATIONS);
767
968
  defaultWrappersSignal = inject(DEFAULT_WRAPPERS, { optional: true });
768
969
  readonlyFieldCache = inject(READONLY_FIELD_TREE_CACHE);
769
- fieldRef;
770
- /**
771
- * VCR the `fieldRef`'s host view is currently inserted into (either the outer
772
- * VCR when there are no wrappers, or the innermost wrapper's `#fieldComponent`
773
- * slot). Tracked so `beforeRebuild` can detach the view before the outer VCR
774
- * cascade-destroys it.
775
- */
776
- fieldSlot;
777
- /** Focus + caret captured during detach, replayed after re-insert. */
778
- focusSnapshot;
970
+ /** Encapsulates Angular's imperative ComponentRef / ViewContainerRef lifecycle. */
971
+ fieldComponent = new FieldComponentSlot();
972
+ componentIdentity = computed(() => this.dfFieldOutlet().component, ...(ngDevMode ? [{ debugName: "componentIdentity" }] : /* istanbul ignore next */ []));
779
973
  /**
780
- * Last rawInputs reference pushed to the innermost field. Used to dedupe the
781
- * rawInputs effect when the same snapshot was already applied as part of the
782
- * initial render — keeps per-keystroke input updates O(changed-keys) instead
783
- * of re-walking the whole input bag.
974
+ * Gate for the wrapper chain controller: true only when required inputs are populated AND the
975
+ * field isn't hidden. Combining the two prevents an initial-mount race where the chain mounts
976
+ * during the brief window between `renderReady` flipping true and `hidden()` settling — that
977
+ * window is exactly where Angular Signal Forms' `[formField]` directive emits NG01916.
784
978
  */
785
- lastPushedInputs;
786
- componentIdentity = computed(() => this.dfFieldOutlet().component, ...(ngDevMode ? [{ debugName: "componentIdentity" }] : /* istanbul ignore next */ []));
787
- renderReady = computed(() => this.dfFieldOutlet().renderReady(), ...(ngDevMode ? [{ debugName: "renderReady" }] : /* istanbul ignore next */ []));
979
+ renderReady = computed(() => this.dfFieldOutlet().renderReady() && !this.dfFieldOutlet().hidden(), ...(ngDevMode ? [{ debugName: "renderReady" }] : /* istanbul ignore next */ []));
788
980
  rawInputs = computed(() => this.dfFieldOutlet().inputs(), ...(ngDevMode ? [{ debugName: "rawInputs" }] : /* istanbul ignore next */ []));
789
981
  /**
790
982
  * Effective wrapper chain. Element-wise identity comparison keeps the signal
@@ -793,11 +985,13 @@ class DfFieldOutlet {
793
985
  */
794
986
  wrappers = computed(() => resolveWrappers(this.dfFieldOutlet().fieldDef, this.defaultWrappersSignal?.(), this.wrapperAutoAssociations), { ...(ngDevMode ? { debugName: "wrappers" } : /* istanbul ignore next */ {}), equal: isSameWrapperChain });
795
987
  /**
796
- * `fieldInputs` bag handed to every wrapper in the chain. Memoised on
797
- * `rawInputs` identity so repeated emissions with the same underlying object
798
- * return the same view and don't cascade OnPush re-evaluations.
988
+ * `fieldInputs` bag handed to every wrapper in the chain AND forwarded to
989
+ * the innermost field component (when it declares `fieldInputs` as an
990
+ * input). Memoised on `rawInputs` identity so repeated emissions with the
991
+ * same underlying object return the same view and don't cascade OnPush
992
+ * re-evaluations.
799
993
  */
800
- fieldInputs = computed(() => this.buildFieldInputs(this.rawInputs()), ...(ngDevMode ? [{ debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
994
+ fieldInputs = computed(() => buildFieldInputs(this.rawInputs(), this.readonlyFieldCache, this.dfFieldOutlet().fieldDef.type), ...(ngDevMode ? [{ debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
801
995
  defaultEnvInjector = inject(EnvironmentInjector);
802
996
  /** Environment injector for the innermost field component — `[environmentInjector]` input takes precedence over the directive's own DI. */
803
997
  fieldEnvInjector = computed(() => this.dfFieldOutletEnvironmentInjector() ?? this.defaultEnvInjector, ...(ngDevMode ? [{ debugName: "fieldEnvInjector" }] : /* istanbul ignore next */ []));
@@ -811,126 +1005,23 @@ class DfFieldOutlet {
811
1005
  rebuildKey: this.componentIdentity,
812
1006
  fieldInputs: this.fieldInputs,
813
1007
  fieldInjector: this.fieldInjector,
814
- beforeRebuild: () => this.detachFieldRef(),
1008
+ beforeRebuild: () => this.fieldComponent.detach(),
815
1009
  renderInnermost: (slot) => {
816
1010
  const resolved = this.dfFieldOutlet();
817
- if (this.fieldRef && this.fieldRef.componentType === resolved.component) {
818
- // Same component class — re-insert the preserved hostView. Browser
819
- // loses focus on detach, so we replay the snapshot captured in
820
- // detachFieldRef. Input value persists on the DOM node itself.
821
- slot.insert(this.fieldRef.hostView);
822
- this.fieldSlot = slot;
823
- this.pushRawInputs(this.fieldRef, this.rawInputs());
824
- this.restoreFocusSnapshot();
825
- return;
826
- }
827
- // Different component class — discard the old ref and create fresh.
828
- // Merge field + element injectors so the field can inject ancestor
829
- // wrappers AND form-context tokens (ARRAY_CONTEXT etc.).
830
- // Clear state before createComponent — if it throws (bad provider,
831
- // invalid class) we don't want to leak a reference to a destroyed ref
832
- // that the same-component branch would later try to re-insert.
833
- this.focusSnapshot = undefined;
834
- this.lastPushedInputs = undefined;
835
- const previousRef = this.fieldRef;
836
- this.fieldRef = undefined;
837
- this.fieldSlot = undefined;
838
- previousRef?.destroy();
839
- this.fieldRef = slot.createComponent(resolved.component, {
840
- environmentInjector: this.fieldEnvInjector(),
841
- injector: createWrapperAwareInjector(resolved.injector, slot.injector),
842
- });
843
- this.fieldSlot = slot;
844
- this.pushRawInputs(this.fieldRef, this.rawInputs());
1011
+ this.fieldComponent.mountOrReuse(slot, resolved.component, resolved.injector, this.fieldEnvInjector(), this.rawInputs(), this.fieldInputs());
845
1012
  },
846
1013
  });
847
- // Push rawInputs to the innermost field. Safe mid-rebuild — renderInnermost
848
- // re-reads rawInputs synchronously on mount, so skipping here loses nothing.
849
- explicitEffect([this.rawInputs], ([rawInputs]) => {
850
- if (!this.fieldRef)
851
- return;
852
- if (this.lastPushedInputs === rawInputs)
853
- return;
854
- this.pushRawInputs(this.fieldRef, rawInputs);
855
- });
856
- this.destroyRef.onDestroy(() => {
857
- // Only destroy when detached — vcr.clear() cascades otherwise.
858
- if (this.fieldRef && !this.fieldSlot)
859
- this.fieldRef.destroy();
860
- this.fieldRef = undefined;
861
- this.fieldSlot = undefined;
862
- this.lastPushedInputs = undefined;
863
- });
864
- }
865
- detachFieldRef() {
866
- if (!this.fieldRef)
867
- return;
868
- if (!this.fieldSlot) {
869
- // Stranded fieldRef — destroy to prevent a leak.
870
- this.fieldRef.destroy();
871
- this.fieldRef = undefined;
872
- return;
873
- }
874
- this.captureFocusSnapshot();
875
- const idx = this.fieldSlot.indexOf(this.fieldRef.hostView);
876
- if (idx >= 0)
877
- this.fieldSlot.detach(idx);
878
- this.fieldSlot = undefined;
879
- }
880
- /** Capture active element + selection before the hostView is detached from DOM. */
881
- captureFocusSnapshot() {
882
- if (!this.fieldRef)
883
- return;
884
- const hostEl = this.fieldRef.location.nativeElement;
885
- const active = document.activeElement;
886
- if (!(active instanceof HTMLElement) || !hostEl.contains(active))
887
- return;
888
- const sel = active;
889
- this.focusSnapshot = {
890
- element: active,
891
- selectionStart: typeof sel.selectionStart === 'number' ? sel.selectionStart : null,
892
- selectionEnd: typeof sel.selectionEnd === 'number' ? sel.selectionEnd : null,
893
- };
894
- }
895
- /** Re-apply the pre-detach focus + caret after the hostView is back in the DOM. */
896
- restoreFocusSnapshot() {
897
- const snap = this.focusSnapshot;
898
- this.focusSnapshot = undefined;
899
- if (!snap || !snap.element.isConnected)
900
- return;
901
- snap.element.focus();
902
- if (snap.selectionStart !== null && snap.selectionEnd !== null && 'setSelectionRange' in snap.element) {
903
- snap.element.setSelectionRange(snap.selectionStart, snap.selectionEnd);
904
- }
905
- }
906
- pushRawInputs(ref, rawInputs) {
907
- // Ref-identity dedupe per key — mappers must emit immutable snapshots.
908
- const last = this.lastPushedInputs;
909
- for (const [key, value] of Object.entries(rawInputs)) {
910
- if (last && last[key] === value)
911
- continue;
912
- setInputIfDeclared(ref, key, value);
913
- }
914
- this.lastPushedInputs = rawInputs;
915
- }
916
- buildFieldInputs(rawInputs) {
917
- const fieldTreeCandidate = rawInputs['field'];
918
- // A FieldTree is a callable (() => FieldState). We expose it to wrappers via a narrow
919
- // read-only view; raw FieldTree is still pushed to the innermost component for writes.
920
- const readonlyField = fieldTreeCandidate && typeof fieldTreeCandidate === 'function'
921
- ? toReadonlyFieldTreeCached(this.readonlyFieldCache, fieldTreeCandidate)
922
- : undefined;
923
- // Shallow spread — relies on the mapper contract (see WrapperFieldInputs)
924
- // that rawInputs are emitted as fresh snapshots, not mutated in place.
925
- return {
926
- ...rawInputs,
927
- field: readonlyField,
928
- };
1014
+ // Push rawInputs (plus the wrapper-style fieldInputs bag) to the innermost
1015
+ // field on every change. The slot dedupes per-key, so the initial render's
1016
+ // synchronous push from `renderInnermost` and the effect's first fire
1017
+ // converge cleanly with no double-pushing.
1018
+ explicitEffect([this.rawInputs, this.fieldInputs], ([rawInputs, fieldInputs]) => this.fieldComponent.pushInputs(rawInputs, fieldInputs));
1019
+ this.destroyRef.onDestroy(() => this.fieldComponent.destroyOnTeardown());
929
1020
  }
930
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DfFieldOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
931
- static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.8", type: DfFieldOutlet, isStandalone: true, selector: "[dfFieldOutlet]", inputs: { dfFieldOutlet: { classPropertyName: "dfFieldOutlet", publicName: "dfFieldOutlet", isSignal: true, isRequired: true, transformFunction: null }, dfFieldOutletEnvironmentInjector: { classPropertyName: "dfFieldOutletEnvironmentInjector", publicName: "dfFieldOutletEnvironmentInjector", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1021
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldOutlet, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1022
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.14", type: DfFieldOutlet, isStandalone: true, selector: "[dfFieldOutlet]", inputs: { dfFieldOutlet: { classPropertyName: "dfFieldOutlet", publicName: "dfFieldOutlet", isSignal: true, isRequired: true, transformFunction: null }, dfFieldOutletEnvironmentInjector: { classPropertyName: "dfFieldOutletEnvironmentInjector", publicName: "dfFieldOutletEnvironmentInjector", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
932
1023
  }
933
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DfFieldOutlet, decorators: [{
1024
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldOutlet, decorators: [{
934
1025
  type: Directive,
935
1026
  args: [{
936
1027
  selector: '[dfFieldOutlet]',
@@ -1230,10 +1321,10 @@ class EventBus {
1230
1321
  }
1231
1322
  return this.pipeline$.pipe(filter((event) => event.type === eventType));
1232
1323
  }
1233
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1234
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventBus });
1324
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventBus, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1325
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventBus });
1235
1326
  }
1236
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventBus, decorators: [{
1327
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventBus, decorators: [{
1237
1328
  type: Injectable
1238
1329
  }] });
1239
1330
 
@@ -2744,16 +2835,25 @@ function evaluateCondition(expression, context) {
2744
2835
  case 'javascript':
2745
2836
  return evaluateJavaScriptExpression(expression, context);
2746
2837
  case 'custom': {
2747
- const customFn = context.customFunctions?.[expression.functionName];
2838
+ // Read both keys via cast — the XOR type narrows to `never` when both
2839
+ // would be truthy, but JSON-loaded configs can produce that state at
2840
+ // runtime. We accept it here, warn, and let inline win.
2841
+ const bothSet = expression;
2842
+ const inlineFn = bothSet.fn;
2843
+ const registeredName = bothSet.functionName;
2844
+ if (inlineFn && registeredName) {
2845
+ context.logger.warn('Both "fn" and "functionName" are set on custom condition. Inline "fn" takes precedence; "functionName" is ignored.');
2846
+ }
2847
+ const customFn = inlineFn ?? (registeredName ? context.customFunctions?.[registeredName] : undefined);
2748
2848
  if (!customFn) {
2749
- context.logger.error('Custom function not found:', expression.functionName);
2849
+ context.logger.error('Custom function not found:', registeredName);
2750
2850
  return false;
2751
2851
  }
2752
2852
  try {
2753
2853
  return !!customFn(context);
2754
2854
  }
2755
2855
  catch (error) {
2756
- context.logger.error('Error executing custom function:', expression.functionName, error);
2856
+ context.logger.error('Error executing custom function:', registeredName ?? '<inline>', error);
2757
2857
  return false;
2758
2858
  }
2759
2859
  }
@@ -2762,7 +2862,7 @@ function evaluateCondition(expression, context) {
2762
2862
  case 'or':
2763
2863
  return evaluateOrCondition(expression, context);
2764
2864
  case 'http':
2765
- context.logger.warn('[Dynamic Forms] HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
2865
+ context.logger.warn('HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
2766
2866
  'When used inside and/or composites, the HTTP result is not available synchronously. Returning false.');
2767
2867
  return false;
2768
2868
  case 'async':
@@ -3298,10 +3398,10 @@ class FunctionRegistryService {
3298
3398
  this.clearAsyncValidators();
3299
3399
  this.clearHttpValidators();
3300
3400
  }
3301
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FunctionRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3302
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FunctionRegistryService });
3401
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3402
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService });
3303
3403
  }
3304
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FunctionRegistryService, decorators: [{
3404
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService, decorators: [{
3305
3405
  type: Injectable
3306
3406
  }] });
3307
3407
 
@@ -3752,10 +3852,10 @@ class FieldContextRegistryService {
3752
3852
  },
3753
3853
  };
3754
3854
  }
3755
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FieldContextRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3756
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FieldContextRegistryService });
3855
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3856
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService });
3757
3857
  }
3758
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FieldContextRegistryService, decorators: [{
3858
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService, decorators: [{
3759
3859
  type: Injectable
3760
3860
  }] });
3761
3861
 
@@ -4110,8 +4210,8 @@ class PageOrchestratorComponent {
4110
4210
  }
4111
4211
  return false;
4112
4212
  }
4113
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4114
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: PageOrchestratorComponent, isStandalone: true, selector: "div[page-orchestrator]", inputs: { pageFields: { classPropertyName: "pageFields", publicName: "pageFields", isSignal: true, isRequired: true, transformFunction: null }, form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, fieldSignalContext: { classPropertyName: "fieldSignalContext", publicName: "fieldSignalContext", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-current-page": "state().currentPageIndex", "attr.data-total-pages": "state().totalPages" }, classAttribute: "df-page-orchestrator" }, providers: [
4213
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PageOrchestratorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4214
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PageOrchestratorComponent, isStandalone: true, selector: "div[page-orchestrator]", inputs: { pageFields: { classPropertyName: "pageFields", publicName: "pageFields", isSignal: true, isRequired: true, transformFunction: null }, form: { classPropertyName: "form", publicName: "form", isSignal: true, isRequired: true, transformFunction: null }, fieldSignalContext: { classPropertyName: "fieldSignalContext", publicName: "fieldSignalContext", isSignal: true, isRequired: true, transformFunction: null } }, host: { properties: { "attr.data-current-page": "state().currentPageIndex", "attr.data-total-pages": "state().totalPages" }, classAttribute: "df-page-orchestrator" }, providers: [
4115
4215
  {
4116
4216
  provide: FIELD_SIGNAL_CONTEXT,
4117
4217
  useFactory: (orchestrator) => orchestrator.extendedFieldSignalContext(),
@@ -4141,9 +4241,9 @@ class PageOrchestratorComponent {
4141
4241
  }
4142
4242
  }
4143
4243
  }
4144
- `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs').then(m => m.default)]] });
4244
+ `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs').then(m => m.default)]] });
4145
4245
  }
4146
- i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
4246
+ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.14", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
4147
4247
  type: Component,
4148
4248
  args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
4149
4249
  @for (pageField of pageFields(); track pageField.key; let i = $index) {
@@ -4575,6 +4675,85 @@ function isValidNonPagedForm(fields) {
4575
4675
  return result.mode === 'non-paged' && result.isValid;
4576
4676
  }
4577
4677
 
4678
+ function createSlot() {
4679
+ let counter = 0;
4680
+ const templates = new Map();
4681
+ const itemOrder = [];
4682
+ const primitiveFieldKey = signal(undefined, ...(ngDevMode ? [{ debugName: "primitiveFieldKey" }] : /* istanbul ignore next */ []));
4683
+ return {
4684
+ templates,
4685
+ itemOrder,
4686
+ primitiveFieldKey,
4687
+ nextId: () => `item-${counter++}`,
4688
+ setTemplate(itemId, t) {
4689
+ templates.set(itemId, t);
4690
+ },
4691
+ getTemplate(itemId) {
4692
+ return templates.get(itemId);
4693
+ },
4694
+ hasTemplate(itemId) {
4695
+ return templates.has(itemId);
4696
+ },
4697
+ setIdAt(index, itemId) {
4698
+ itemOrder[index] = itemId;
4699
+ },
4700
+ removeAt(index) {
4701
+ const [removed] = itemOrder.splice(index, 1);
4702
+ if (removed !== undefined)
4703
+ templates.delete(removed);
4704
+ },
4705
+ moveItem(fromIndex, toIndex) {
4706
+ const [id] = itemOrder.splice(fromIndex, 1);
4707
+ if (id !== undefined)
4708
+ itemOrder.splice(toIndex, 0, id);
4709
+ },
4710
+ truncate(length) {
4711
+ const removed = itemOrder.splice(length);
4712
+ for (const id of removed)
4713
+ templates.delete(id);
4714
+ },
4715
+ clear() {
4716
+ templates.clear();
4717
+ itemOrder.length = 0;
4718
+ counter = 0;
4719
+ primitiveFieldKey.set(undefined);
4720
+ },
4721
+ };
4722
+ }
4723
+ /**
4724
+ * Form-scoped registry of per-array durable state. One slot per array path
4725
+ * (e.g. 'members', 'team.members', 'orders.lineItems'). Survives
4726
+ * `ArrayFieldComponent` lifecycle, dies with the `DynamicForm`. Cleared on
4727
+ * schema swap by the `formSetup` effect in `FormStateManager`.
4728
+ *
4729
+ * `ArrayFieldComponent` injects this service and obtains its slot via
4730
+ * `slotFor(arrayPath)` on mount. Replaces the prior per-component
4731
+ * `ARRAY_TEMPLATE_REGISTRY` + `ARRAY_ITEM_ID_GENERATOR` providers.
4732
+ */
4733
+ class ArrayItemRegistryService {
4734
+ slots = new Map();
4735
+ /** Get-or-create a slot for the array at the given path. */
4736
+ slotFor(arrayPath) {
4737
+ let slot = this.slots.get(arrayPath);
4738
+ if (!slot) {
4739
+ slot = createSlot();
4740
+ this.slots.set(arrayPath, slot);
4741
+ }
4742
+ return slot;
4743
+ }
4744
+ /** Drop all slots — called from the `formSetup` effect on schema change. */
4745
+ clearAll() {
4746
+ for (const slot of this.slots.values())
4747
+ slot.clear();
4748
+ this.slots.clear();
4749
+ }
4750
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4751
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService });
4752
+ }
4753
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService, decorators: [{
4754
+ type: Injectable
4755
+ }] });
4756
+
4578
4757
  class SchemaRegistryService {
4579
4758
  registeredSchemas = new Map();
4580
4759
  /**
@@ -4610,10 +4789,10 @@ class SchemaRegistryService {
4610
4789
  }
4611
4790
  return schema;
4612
4791
  }
4613
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SchemaRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4614
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SchemaRegistryService });
4792
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4793
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService });
4615
4794
  }
4616
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SchemaRegistryService, decorators: [{
4795
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService, decorators: [{
4617
4796
  type: Injectable
4618
4797
  }] });
4619
4798
 
@@ -4624,7 +4803,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
4624
4803
  * Use this guard when you need type-safe access to `FunctionHttpValidatorConfig` properties.
4625
4804
  */
4626
4805
  function isFunctionHttpValidator(config) {
4627
- return 'functionName' in config && !!config.functionName;
4806
+ const c = config;
4807
+ return typeof c.functionName === 'string' || (c.fn !== null && typeof c.fn === 'object');
4628
4808
  }
4629
4809
 
4630
4810
  /**
@@ -4820,8 +5000,6 @@ class HttpConditionFunctionCacheService {
4820
5000
  * );
4821
5001
  * // user.value() keeps the old user data during loading transitions
4822
5002
  * ```
4823
- *
4824
- * @experimental Uses Angular's experimental resource composition APIs.
4825
5003
  */
4826
5004
  function withPreviousValue(input) {
4827
5005
  const derived = linkedSignal({ ...(ngDevMode ? { debugName: "derived" } : /* istanbul ignore next */ {}), source: input.snapshot,
@@ -5043,11 +5221,18 @@ function createAsyncConditionLogicFunction(condition) {
5043
5221
  const cacheService = inject(AsyncConditionFunctionCacheService);
5044
5222
  const pendingValue = condition.pendingValue ?? false;
5045
5223
  const debounceMs = condition.debounceMs ?? 300;
5046
- // Check function cache
5047
- const cacheKey = stableStringify(condition);
5048
- const cached = cacheService.asyncConditionFunctionCache.get(cacheKey);
5049
- if (cached) {
5050
- return cached;
5224
+ if (condition.asyncFn && condition.asyncFunctionName) {
5225
+ logger.warn('Both "asyncFn" and "asyncFunctionName" are set on async condition. Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.');
5226
+ }
5227
+ // Skip cross-condition caching when an inline function is used: stableStringify
5228
+ // cannot distinguish two different inline functions, which would cause cache hits
5229
+ // to wrongly reuse a LogicFn closing over a different function reference.
5230
+ const cacheKey = condition.asyncFn ? undefined : stableStringify(condition);
5231
+ if (cacheKey !== undefined) {
5232
+ const cached = cacheService.asyncConditionFunctionCache.get(cacheKey);
5233
+ if (cached) {
5234
+ return cached;
5235
+ }
5051
5236
  }
5052
5237
  // Each condition needs its own per-field signal store.
5053
5238
  const perFunctionSignalStore = new Map();
@@ -5072,8 +5257,10 @@ function createAsyncConditionLogicFunction(condition) {
5072
5257
  const asyncResource = rxResource({
5073
5258
  params: () => debouncedTrigger() ?? undefined,
5074
5259
  stream: ({ params: triggerKey }) => {
5075
- // Look up the async function at call time (fresh reference)
5076
- const asyncFn = functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName);
5260
+ // Inline `asyncFn` wins; otherwise look up the registered function at call time
5261
+ // (fresh reference, in case the registry was updated after LogicFn creation).
5262
+ const asyncFn = condition.asyncFn ??
5263
+ (condition.asyncFunctionName ? functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName) : undefined);
5077
5264
  if (!asyncFn) {
5078
5265
  logger.warn(`Async Condition - function '${condition.asyncFunctionName}' not found. ` +
5079
5266
  `Register it in customFnConfig.asyncConditions.`);
@@ -5125,7 +5312,9 @@ function createAsyncConditionLogicFunction(condition) {
5125
5312
  });
5126
5313
  return resultResource.value();
5127
5314
  };
5128
- cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
5315
+ if (cacheKey !== undefined) {
5316
+ cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
5317
+ }
5129
5318
  return fn;
5130
5319
  }
5131
5320
 
@@ -5760,12 +5949,12 @@ function applyCustomValidator(config, fieldPath) {
5760
5949
  if (config.expression) {
5761
5950
  validatorFn = createExpressionValidator(config);
5762
5951
  }
5763
- else if (config.functionName) {
5952
+ else if (config.fn || config.functionName) {
5764
5953
  validatorFn = createFunctionValidator(config);
5765
5954
  }
5766
5955
  else {
5767
5956
  const logger = inject(DynamicFormLogger);
5768
- logger.warn('Custom validator must have either "expression" or "functionName"');
5957
+ logger.warn('Custom validator must have one of "expression", "functionName", or "fn"');
5769
5958
  return;
5770
5959
  }
5771
5960
  const whenLogic = createConditionalLogic(config.when);
@@ -5777,6 +5966,13 @@ function applyCustomValidator(config, fieldPath) {
5777
5966
  }
5778
5967
  function createFunctionValidator(config) {
5779
5968
  const logger = inject(DynamicFormLogger);
5969
+ if (config.fn && config.functionName) {
5970
+ logger.warn('Both "fn" and "functionName" are set on custom validator. Inline "fn" takes precedence; "functionName" is ignored.');
5971
+ }
5972
+ if (config.fn) {
5973
+ const inlineFn = config.fn;
5974
+ return (ctx) => inlineFn(ctx, config.params);
5975
+ }
5780
5976
  const functionName = config.functionName;
5781
5977
  if (!functionName) {
5782
5978
  logger.warn('Custom validator missing functionName');
@@ -5837,10 +6033,24 @@ function createExpressionValidator(config) {
5837
6033
  * - onError: Optional handler for resource errors
5838
6034
  */
5839
6035
  function applyAsyncValidator(config, fieldPath) {
5840
- const registry = inject(FunctionRegistryService);
5841
- const validatorConfig = registry.getAsyncValidator(config.functionName);
5842
- if (!validatorConfig) {
5843
- throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
6036
+ if (config.fn && config.functionName) {
6037
+ const logger = inject(DynamicFormLogger);
6038
+ logger.warn('Both "fn" and "functionName" are set on async validator. Inline "fn" takes precedence; "functionName" is ignored.');
6039
+ }
6040
+ let validatorConfig;
6041
+ if (config.fn) {
6042
+ validatorConfig = config.fn;
6043
+ }
6044
+ else if (config.functionName) {
6045
+ const registry = inject(FunctionRegistryService);
6046
+ validatorConfig = registry.getAsyncValidator(config.functionName);
6047
+ if (!validatorConfig) {
6048
+ throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
6049
+ }
6050
+ }
6051
+ else {
6052
+ // XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
6053
+ throw new DynamicFormError('Async validator requires one of "functionName" or "fn".');
5844
6054
  }
5845
6055
  const whenLogic = createConditionalLogic(config.when);
5846
6056
  const asyncOptions = {
@@ -5862,10 +6072,16 @@ function applyAsyncValidator(config, fieldPath) {
5862
6072
  function applyUnifiedHttpValidator(config, fieldPath) {
5863
6073
  if (isFunctionHttpValidator(config)) {
5864
6074
  applyFunctionHttpValidator(config, fieldPath);
6075
+ return;
5865
6076
  }
5866
- else {
5867
- applyDeclarativeHttpValidator(config, fieldPath);
6077
+ // Declarative path requires both `http` and `responseMapping` — reject early
6078
+ // for JSON-loaded configs that have neither side fully populated. The
6079
+ // discriminated union forbids this at compile time.
6080
+ const declarative = config;
6081
+ if (!declarative.http || !declarative.responseMapping) {
6082
+ throw new DynamicFormError('HTTP validator requires one of "functionName" / "fn" (function-based) or "http" + "responseMapping" (declarative).');
5868
6083
  }
6084
+ applyDeclarativeHttpValidator(config, fieldPath);
5869
6085
  }
5870
6086
  /**
5871
6087
  * Apply function-based HTTP validator to field path using Angular's public validateHttp() API.
@@ -5876,10 +6092,24 @@ function applyUnifiedHttpValidator(config, fieldPath) {
5876
6092
  * - onError: Optional handler for HTTP errors
5877
6093
  */
5878
6094
  function applyFunctionHttpValidator(config, fieldPath) {
5879
- const registry = inject(FunctionRegistryService);
5880
- const httpValidatorConfig = registry.getHttpValidator(config.functionName);
5881
- if (!httpValidatorConfig) {
5882
- throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
6095
+ if (config.fn && config.functionName) {
6096
+ const logger = inject(DynamicFormLogger);
6097
+ logger.warn('Both "fn" and "functionName" are set on HTTP validator. Inline "fn" takes precedence; "functionName" is ignored.');
6098
+ }
6099
+ let httpValidatorConfig;
6100
+ if (config.fn) {
6101
+ httpValidatorConfig = config.fn;
6102
+ }
6103
+ else if (config.functionName) {
6104
+ const registry = inject(FunctionRegistryService);
6105
+ httpValidatorConfig = registry.getHttpValidator(config.functionName);
6106
+ if (!httpValidatorConfig) {
6107
+ throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
6108
+ }
6109
+ }
6110
+ else {
6111
+ // XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
6112
+ throw new DynamicFormError('HTTP validator requires one of "functionName" or "fn".');
5883
6113
  }
5884
6114
  const whenLogic = createConditionalLogic(config.when);
5885
6115
  const httpOptions = {
@@ -8232,9 +8462,13 @@ function buildClassName(fieldDef) {
8232
8462
  * @returns Record of input names to values
8233
8463
  */
8234
8464
  function buildBaseInputs(fieldDef, defaultProps) {
8235
- const { key, label, className, tabIndex, props, meta } = fieldDef;
8465
+ const { key, label, className, tabIndex, props, meta, addons } = fieldDef;
8236
8466
  const inputs = {};
8237
- // Always include key - required by components for accessibility and identification
8467
+ // Always include key — required by components for accessibility and identification.
8468
+ // `type` is intentionally NOT propagated to the field component (it's the
8469
+ // registry discriminant, not a component input); wrappers and addons read it
8470
+ // from `WrapperFieldInputs.type` which is injected at the buildFieldInputs()
8471
+ // boundary by `DfFieldOutlet`.
8238
8472
  inputs['key'] = key;
8239
8473
  if (label !== undefined) {
8240
8474
  inputs['label'] = label;
@@ -8264,6 +8498,13 @@ function buildBaseInputs(fieldDef, defaultProps) {
8264
8498
  if (meta !== undefined) {
8265
8499
  inputs['meta'] = meta;
8266
8500
  }
8501
+ // Forward addons. Field components that don't declare an `addons` input
8502
+ // signal (Tier 3 — toggle, checkbox, radio, slider) ignore this key via
8503
+ // Angular's `setInput` no-op-on-unknown behaviour, so it's safe to forward
8504
+ // unconditionally.
8505
+ if (addons !== undefined) {
8506
+ inputs['addons'] = addons;
8507
+ }
8267
8508
  return inputs;
8268
8509
  }
8269
8510
  /**
@@ -8463,22 +8704,31 @@ const PLACEHOLDER_INDEX = '$';
8463
8704
  /**
8464
8705
  * Builds a property override store key for a field.
8465
8706
  *
8466
- * For non-array fields, the key is just the field's key.
8467
- * For array item fields, the key includes the array path and either a concrete
8468
- * index (e.g., `contacts.0.email`) or the `$` placeholder (e.g., `contacts.$.email`).
8707
+ * For non-array fields, the key is the dot-joined group ancestors plus the
8708
+ * field's leaf key (e.g., `address.street` for `street` inside `address`).
8709
+ * For array item fields, the key includes the array path, either a concrete
8710
+ * index or the `$` placeholder, and any group ancestors INSIDE the array
8711
+ * item (e.g., `contacts.0.email`, `contacts.$.email`, or
8712
+ * `contacts.0.profile.email` for a group nested under each contact).
8713
+ *
8714
+ * `groupPath` matches the form-value path of nested groups (collector resets
8715
+ * it at array boundaries — see `property-derivation-collector.ts`), so the
8716
+ * resulting key always mirrors the field's location in `formValue`.
8469
8717
  *
8470
8718
  * @param arrayKey - The array field key (e.g., 'contacts'), or undefined for non-array fields
8471
8719
  * @param index - The array item index, `PLACEHOLDER_INDEX` for the wildcard format, or undefined for non-array fields
8472
8720
  * @param fieldKey - The leaf field key (e.g., 'email')
8473
- * @returns The store key (e.g., 'email', 'contacts.0.email', or 'contacts.$.email')
8721
+ * @param groupPath - Dot-joined group ancestors scoped to the current array item (or top-level), or undefined when not inside any group
8722
+ * @returns The store key (e.g., 'email', 'address.street', 'contacts.0.email', 'contacts.$.profile.email')
8474
8723
  *
8475
8724
  * @public
8476
8725
  */
8477
- function buildPropertyOverrideKey(arrayKey, index, fieldKey) {
8726
+ function buildPropertyOverrideKey(arrayKey, index, fieldKey, groupPath) {
8727
+ const leafScope = groupPath ? `${groupPath}.${fieldKey}` : fieldKey;
8478
8728
  if (arrayKey != null && index != null) {
8479
- return `${arrayKey}.${index}.${fieldKey}`;
8729
+ return `${arrayKey}.${index}.${leafScope}`;
8480
8730
  }
8481
- return fieldKey;
8731
+ return leafScope;
8482
8732
  }
8483
8733
 
8484
8734
  /**
@@ -8559,6 +8809,25 @@ function applyIndexSuffix(inputs, index) {
8559
8809
  key: `${key}_${index}`,
8560
8810
  };
8561
8811
  }
8812
+ /**
8813
+ * Prefixes the key with the underscored group ancestor path so that the same
8814
+ * leaf key inside different groups produces distinct DOM IDs (issue #401).
8815
+ * Form-schema keys remain clean — only the rendered `key` input is rewritten.
8816
+ *
8817
+ * Dots in the group path become underscores, then the original leaf key is
8818
+ * appended: `(groupPath='user.address', inputs.key='street')` →
8819
+ * `inputs.key = 'user_address_street'`.
8820
+ */
8821
+ function applyGroupPrefix(inputs, groupPath) {
8822
+ const key = inputs['key'];
8823
+ if (typeof key !== 'string' || groupPath.length === 0) {
8824
+ return inputs;
8825
+ }
8826
+ return {
8827
+ ...inputs,
8828
+ key: `${groupPath.replace(/\./g, '_')}_${key}`,
8829
+ };
8830
+ }
8562
8831
  /**
8563
8832
  * Main field mapper function that uses the field registry to get the appropriate mapper
8564
8833
  * based on the field's type property.
@@ -8595,6 +8864,10 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8595
8864
  // Check if we're inside an array item context - if so, we need to suffix keys
8596
8865
  // Optional because ARRAY_CONTEXT is only provided inside array item injectors
8597
8866
  const arrayContext = inject(ARRAY_CONTEXT, { optional: true });
8867
+ // Check if we're inside a group context - if so, prefix the key with the group path
8868
+ // so overlapping leaf keys across groups produce distinct DOM IDs (issue #401).
8869
+ // Optional because GROUP_CONTEXT is only provided by GroupFieldComponent.
8870
+ const groupContext = inject(GROUP_CONTEXT, { optional: true });
8598
8871
  // Inject the property override store for property derivation overrides
8599
8872
  // Always available — provided at the DynamicForm component level via provideDynamicFormDI
8600
8873
  const store = inject(PROPERTY_OVERRIDE_STORE);
@@ -8605,6 +8878,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8605
8878
  // Check that arrayContext exists and has a valid index signal (guards against mock injectors)
8606
8879
  const indexSignal = arrayContext?.index;
8607
8880
  const hasArrayContext = indexSignal !== undefined && isSignal(indexSignal);
8881
+ const hasGroupContext = groupContext != null;
8608
8882
  // Fast-path check for property overrides: only fields whose definition declares
8609
8883
  // a property-derivation enter the computed() wrapper. We inspect the FieldDef
8610
8884
  // directly rather than asking the store, because the store's registration
@@ -8613,7 +8887,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8613
8887
  // collector apply the same inclusion rule, so the answers always agree.
8614
8888
  const hasOverrides = fieldHasPropertyDerivations(fieldDef);
8615
8889
  // Fast path: no transformations needed
8616
- if (!hasPropsForwarding && !hasArrayContext && !hasOverrides) {
8890
+ if (!hasPropsForwarding && !hasArrayContext && !hasGroupContext && !hasOverrides) {
8617
8891
  return mapperResult;
8618
8892
  }
8619
8893
  // Wrap in computed to apply transformations
@@ -8623,18 +8897,34 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8623
8897
  if (hasPropsForwarding) {
8624
8898
  inputs = mergeForwardedPropsToMeta(inputs, propsToMeta);
8625
8899
  }
8900
+ // Apply group prefix BEFORE array suffix so the resulting key reads as
8901
+ // `{groupPath}_{key}_{index}` (e.g. `address_street_0`) — matching the
8902
+ // form-value path order: group → leaf → array index.
8903
+ if (hasGroupContext) {
8904
+ // Non-null after the hasGroupContext guard.
8905
+ inputs = applyGroupPrefix(inputs, groupContext.groupPath());
8906
+ }
8626
8907
  // Apply index suffix for array items
8627
8908
  if (hasArrayContext) {
8628
8909
  const index = indexSignal();
8629
8910
  inputs = applyIndexSuffix(inputs, index);
8630
8911
  }
8631
- // Apply property overrides from the store (AFTER all static transformations)
8912
+ // Apply property overrides from the store (AFTER all static transformations).
8913
+ //
8914
+ // INVARIANT: the store key MUST be built from `fieldDef.key` + the group/array
8915
+ // ancestry (via buildPropertyOverrideKey) — NEVER from the DOM-scoped
8916
+ // `inputs['key']` produced by applyGroupPrefix/applyIndexSuffix above. The
8917
+ // collector (property-derivation-collector.ts) registers using the same
8918
+ // shape; if these drift, overrides silently miss their target.
8632
8919
  if (hasOverrides) {
8633
- // Build the store key inside computed() so index signal read establishes reactive dependency
8634
- // Safe to access arrayContext/indexSignal directly — hasArrayContext already confirmed they exist
8920
+ // Build inside computed() so the index/group signals establish reactive deps.
8921
+ // GROUP_CONTEXT resets at array boundaries (see create-array-item-injector.ts),
8922
+ // so groupPath() represents ancestors inside the current array item (or
8923
+ // top-level groups when not in an array) — matching the collector.
8924
+ const groupPath = hasGroupContext ? groupContext.groupPath() : undefined;
8635
8925
  const key = hasArrayContext
8636
- ? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key)
8637
- : fieldDef.key;
8926
+ ? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key, groupPath || undefined)
8927
+ : buildPropertyOverrideKey(undefined, undefined, fieldDef.key, groupPath || undefined);
8638
8928
  const overrides = store.getOverrides(key)();
8639
8929
  inputs = applyPropertyOverrides(inputs, overrides);
8640
8930
  }
@@ -8653,6 +8943,40 @@ function createRenderReadySignal(inputs, definition) {
8653
8943
  return requiredInputs.every((inputName) => currentInputs[inputName] !== undefined);
8654
8944
  });
8655
8945
  }
8946
+ /**
8947
+ * Reads the current hidden state from mapper inputs. Uniform across all field
8948
+ * types — value-bearing leaves source from the `FieldTree`'s `state.hidden()`
8949
+ * (where Angular Signal Forms cascades `hidden(path, …)`), and non-field outputs
8950
+ * (button/text/containers) source from the mapper's explicit `inputs.hidden`.
8951
+ *
8952
+ * The `@if (!field.hidden())` gate at every `*dfFieldOutlet` consumption site uses
8953
+ * this signal, removing hidden fields from the DOM. That's required to silence
8954
+ * Angular's `NG01916: Field 'X' is hidden but is being rendered` warning — and
8955
+ * is the prescribed pattern from the
8956
+ * [`hidden` API docs](https://angular.dev/api/forms/signals/hidden).
8957
+ *
8958
+ * Container internal state (e.g. an `ArrayFieldComponent`'s items + dynamic
8959
+ * templates + id counter) survives this destroy/recreate via the form-scoped
8960
+ * `ArrayItemRegistryService` keyed by array path.
8961
+ */
8962
+ function createHiddenSignal(inputs) {
8963
+ return computed(() => {
8964
+ const currentInputs = inputs();
8965
+ const fieldCandidate = currentInputs['field'];
8966
+ // Value-bearing leaves expose `field` as a callable `FieldTree` whose invocation returns a
8967
+ // `FieldState` with a `hidden` accessor. `isSignal()` doesn't recognise the Proxy-wrapped
8968
+ // FieldTree (no SIGNAL symbol), so we duck-type the shape and call `state.hidden()` directly.
8969
+ if (typeof fieldCandidate === 'function') {
8970
+ const state = fieldCandidate();
8971
+ if (state && typeof state.hidden === 'function') {
8972
+ return state.hidden() === true;
8973
+ }
8974
+ }
8975
+ // Non-value fields (button, text, containers) carry an explicit `hidden` input set by
8976
+ // `applyHiddenLogic` based on the mapper-side evaluation.
8977
+ return currentInputs['hidden'] === true;
8978
+ });
8979
+ }
8656
8980
  /**
8657
8981
  * Resolves a single field definition to a ResolvedField using RxJS.
8658
8982
  * Loads the component asynchronously and maps inputs in the injection context.
@@ -8689,6 +9013,7 @@ function resolveField(fieldDef, context) {
8689
9013
  injector: context.injector,
8690
9014
  inputs,
8691
9015
  renderReady: createRenderReadySignal(inputs, definition),
9016
+ hidden: createHiddenSignal(inputs),
8692
9017
  };
8693
9018
  }), catchError((error) => {
8694
9019
  // Only call onError if component is not destroyed to avoid accessing cleaned-up state
@@ -8725,6 +9050,7 @@ function resolveFieldSync(fieldDef, context) {
8725
9050
  injector: context.injector,
8726
9051
  inputs,
8727
9052
  renderReady: createRenderReadySignal(inputs, definition),
9053
+ hidden: createHiddenSignal(inputs),
8728
9054
  };
8729
9055
  }
8730
9056
  /**
@@ -8751,13 +9077,14 @@ function reconcileFields(prev, curr) {
8751
9077
  }
8752
9078
  // Same component instance + injector, but the field definition changed.
8753
9079
  // Preserve component identity (so ngComponentOutlet keeps the same instance)
8754
- // while swapping in the freshly-mapped inputs/renderReady signals so prop
8755
- // updates (e.g. select options) reach the component.
9080
+ // while swapping in the freshly-mapped inputs/renderReady/hidden signals
9081
+ // so prop updates (e.g. select options) reach the component.
8756
9082
  return {
8757
9083
  ...existing,
8758
9084
  fieldDef: field.fieldDef,
8759
9085
  inputs: field.inputs,
8760
9086
  renderReady: field.renderReady,
9087
+ hidden: field.hidden,
8761
9088
  };
8762
9089
  }
8763
9090
  // New field, type changed, or context changed (new injector) - use new field
@@ -9075,15 +9402,32 @@ function injectFieldRegistry() {
9075
9402
  * Collects all field keys, types, and validates regex patterns from a field tree
9076
9403
  * by recursively traversing containers (page, row, group, array).
9077
9404
  *
9078
- * @param collectKeys - Whether to add field keys to data.keys for duplicate detection.
9079
- * Set to false when inside an array container: array item fields share template keys
9080
- * across items by design (e.g. every item has 'name', 'email'), so they must not
9081
- * participate in global duplicate-key checking.
9405
+ * Keys are scoped by their group ancestors (e.g. `address.city` instead of `city`),
9406
+ * so the same leaf key can appear inside different groups without conflict. Page and
9407
+ * row containers do not contribute to the path because they flatten their children
9408
+ * into the parent scope. Array items are excluded entirely from key collection
9409
+ * because they share template keys across items by design.
9410
+ *
9411
+ * Fields whose registered `valueHandling` is anything other than `include` (e.g.
9412
+ * buttons with `exclude`, page/row with `flatten`) are not collected: they don't
9413
+ * bind to a form-value path, so duplicate-key uniqueness isn't meaningful for them.
9414
+ *
9415
+ * @param collectKeys - When false, the entire subtree skips key collection (used
9416
+ * when descending into an array container).
9082
9417
  */
9083
- function collectFieldData(fields, data, collectKeys = true) {
9418
+ function collectFieldData(fields, data, registry, pathPrefix, collectKeys = true) {
9084
9419
  for (const field of fields) {
9085
- if (collectKeys && field.key) {
9086
- data.keys.push(field.key);
9420
+ // Defensively skip only known non-include modes — unexpected/missing
9421
+ // valueHandling defaults back to participating in duplicate detection so a
9422
+ // mis-registered field type doesn't silently drop from collision checks.
9423
+ const valueHandling = field.type ? getFieldValueHandling(field.type, registry) : 'include';
9424
+ const participatesInValue = valueHandling !== 'exclude' && valueHandling !== 'flatten';
9425
+ if (collectKeys && field.key && participatesInValue) {
9426
+ const scopedKey = pathPrefix ? `${pathPrefix}.${field.key}` : field.key;
9427
+ data.keys.push(scopedKey);
9428
+ // Project to the DOM-ID format (dots → underscores) so we can detect
9429
+ // collisions like top-level `'foo_bar'` vs group `'foo'` + leaf `'bar'`.
9430
+ data.domIds.push(scopedKey.replace(/\./g, '_'));
9087
9431
  }
9088
9432
  if (field.type) {
9089
9433
  data.types.add(field.type);
@@ -9103,13 +9447,16 @@ function collectFieldData(fields, data, collectKeys = true) {
9103
9447
  // Array item fields share template keys across items by design — stop collecting keys
9104
9448
  // once inside an array. Types and regex patterns are still validated.
9105
9449
  const childCollectKeys = collectKeys && field.type !== 'array';
9450
+ // Only group containers extend the scoping path — page/row flatten into the parent
9451
+ // scope, and array items use dynamic indices that aren't statically knowable.
9452
+ const childPrefix = isGroupField(field) && field.key ? (pathPrefix ? `${pathPrefix}.${field.key}` : field.key) : pathPrefix;
9106
9453
  const children = normalizeFieldsArray(field.fields);
9107
9454
  for (const child of children) {
9108
9455
  if (Array.isArray(child)) {
9109
- collectFieldData(child, data, childCollectKeys);
9456
+ collectFieldData(child, data, registry, childPrefix, childCollectKeys);
9110
9457
  }
9111
9458
  else {
9112
- collectFieldData([child], data, childCollectKeys);
9459
+ collectFieldData([child], data, registry, childPrefix, childCollectKeys);
9113
9460
  }
9114
9461
  }
9115
9462
  }
@@ -9145,7 +9492,38 @@ function validateNoDuplicateKeys(allKeys) {
9145
9492
  const duplicateList = Array.from(duplicates)
9146
9493
  .map((k) => `'${k}'`)
9147
9494
  .join(', ');
9148
- throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}. Each field key must be unique within a form config.`);
9495
+ throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}. Field keys must be unique within their group scope (the same key may appear inside different groups).`);
9496
+ }
9497
+ }
9498
+ /**
9499
+ * Validates that the underscore-projected DOM-ID namespace has no collisions.
9500
+ *
9501
+ * DOM IDs are built by underscore-joining the group ancestor path with the
9502
+ * leaf key (e.g. `address_street`). Two scoped paths can land on the same
9503
+ * DOM ID even when their form-value paths differ — e.g. a top-level key
9504
+ * `'foo_bar'` and a leaf `'bar'` inside group `'foo'` both render as
9505
+ * `id="foo_bar"`. The form-value-path check accepts this (paths are
9506
+ * `'foo_bar'` vs `'foo.bar'` — distinct) but the rendered DOM would have
9507
+ * duplicate IDs, breaking aria associations and CSS selectors.
9508
+ *
9509
+ * @throws {DynamicFormError} When two scoped paths project to the same DOM ID
9510
+ */
9511
+ function validateNoDomIdCollisions(domIds) {
9512
+ const seen = new Set();
9513
+ const duplicates = new Set();
9514
+ for (const id of domIds) {
9515
+ if (seen.has(id)) {
9516
+ duplicates.add(id);
9517
+ }
9518
+ seen.add(id);
9519
+ }
9520
+ if (duplicates.size > 0) {
9521
+ const duplicateList = Array.from(duplicates)
9522
+ .map((k) => `'${k}'`)
9523
+ .join(', ');
9524
+ throw new DynamicFormError(`DOM id collision detected for: ${duplicateList}. Group-nested keys are joined with '_' to form DOM ids ` +
9525
+ `(e.g. group 'foo' + leaf 'bar' renders as id='foo_bar'). A separate top-level key like 'foo_bar' or a different ` +
9526
+ `group/leaf pair that underscores to the same string will produce duplicate ids. Rename one of the colliding keys.`);
9149
9527
  }
9150
9528
  }
9151
9529
  /**
@@ -9183,11 +9561,13 @@ function validateFieldTypesRegistered(allTypes, registry, logger) {
9183
9561
  function validateFormConfig(fields, registry, logger) {
9184
9562
  const data = {
9185
9563
  keys: [],
9564
+ domIds: [],
9186
9565
  types: new Set(),
9187
9566
  regexErrors: [],
9188
9567
  };
9189
- collectFieldData(fields, data);
9568
+ collectFieldData(fields, data, registry, '');
9190
9569
  validateNoDuplicateKeys(data.keys);
9570
+ validateNoDomIdCollisions(data.domIds);
9191
9571
  validateFieldTypesRegistered(data.types, registry, logger);
9192
9572
  if (data.regexErrors.length > 0) {
9193
9573
  throw new DynamicFormError(data.regexErrors.length === 1
@@ -9196,6 +9576,272 @@ function validateFormConfig(fields, registry, logger) {
9196
9576
  }
9197
9577
  }
9198
9578
 
9579
+ /**
9580
+ * Walk a field's addons; return the survivors plus a list of warnings for
9581
+ * anything dropped.
9582
+ *
9583
+ * Pure: receives the registries as arguments (so the function is reusable
9584
+ * outside Angular's DI — tests, server-side validation, etc.).
9585
+ *
9586
+ * @param field The field to validate.
9587
+ * @param fieldRegistry Snapshot of `FIELD_REGISTRY` map.
9588
+ * @param kindRegistry Snapshot of `ADDON_KIND_REGISTRY` map.
9589
+ * @param source Whether the config originated from JSON (lenient drop
9590
+ * of code-only kinds) or inline TypeScript.
9591
+ */
9592
+ function validateFieldAddons(field, fieldRegistry, kindRegistry, source) {
9593
+ const warnings = [];
9594
+ const incoming = field.addons;
9595
+ if (!incoming?.length) {
9596
+ return { addons: [], warnings };
9597
+ }
9598
+ const fieldType = field.type;
9599
+ const fieldDef = fieldRegistry.get(fieldType);
9600
+ if (!fieldDef) {
9601
+ warnings.push({ type: 'unknown-field-type', fieldKey: field.key, fieldType });
9602
+ return { addons: [], warnings };
9603
+ }
9604
+ const addonSupport = fieldDef.addons;
9605
+ if (!addonSupport) {
9606
+ warnings.push({ type: 'field-type-no-addon-support', fieldKey: field.key, fieldType });
9607
+ return { addons: [], warnings };
9608
+ }
9609
+ const allowedSlots = addonSupport.slots;
9610
+ const allowedKinds = addonSupport.allowedKinds;
9611
+ const registeredKinds = Array.from(kindRegistry.keys());
9612
+ const survivors = [];
9613
+ for (const addon of incoming) {
9614
+ // Slot allowed?
9615
+ if (!allowedSlots.includes(addon.slot)) {
9616
+ warnings.push({
9617
+ type: 'unknown-slot',
9618
+ fieldKey: field.key,
9619
+ fieldType,
9620
+ slot: String(addon.slot),
9621
+ allowedSlots,
9622
+ });
9623
+ continue;
9624
+ }
9625
+ // Kind registered? `AnyAddon.kind` is already a string-literal union via
9626
+ // DynamicFormAddonRegistry; no need to cast.
9627
+ const kindDef = kindRegistry.get(addon.kind);
9628
+ if (!kindDef) {
9629
+ warnings.push({
9630
+ type: 'unknown-kind',
9631
+ fieldKey: field.key,
9632
+ kind: addon.kind,
9633
+ registeredKinds,
9634
+ });
9635
+ continue;
9636
+ }
9637
+ // Kind whitelisted (if `allowedKinds` was set)?
9638
+ if (allowedKinds && !allowedKinds.includes(kindDef.kind)) {
9639
+ warnings.push({
9640
+ type: 'kind-not-allowed',
9641
+ fieldKey: field.key,
9642
+ fieldType,
9643
+ kind: kindDef.kind,
9644
+ allowedKinds,
9645
+ });
9646
+ continue;
9647
+ }
9648
+ // Code-only kind dropped from JSON-source configs. A kind opts out of
9649
+ // JSON-safety via `AddonKindDefinition.jsonSafe: false` (defaults to true).
9650
+ if (source === 'json' && kindDef.jsonSafe === false) {
9651
+ warnings.push({
9652
+ type: 'code-only-kind-in-json',
9653
+ fieldKey: field.key,
9654
+ kind: kindDef.kind,
9655
+ });
9656
+ continue;
9657
+ }
9658
+ // Multi-set XOR + JSON-source function stripping. Both runs operate on
9659
+ // a working copy via `ensureCopy()` so the original config isn't
9660
+ // mutated — the survivor pushed at the bottom is either the original
9661
+ // addon (no strips) or the sanitized copy.
9662
+ let sanitized = addon;
9663
+ let stripped = false;
9664
+ const ensureCopy = () => {
9665
+ if (!stripped)
9666
+ sanitized = { ...sanitized };
9667
+ stripped = true;
9668
+ };
9669
+ // Snapshot which click variants the original config declared BEFORE
9670
+ // any stripping. Computing variants AFTER function-strip would
9671
+ // underreport the multi-variant warning (`action` would silently
9672
+ // vanish from the reported list).
9673
+ const declaredVariants = ['preset', 'actionRef', 'action'].filter((k) => sanitized[k] !== undefined);
9674
+ // JSON-source function strip — inline `action`/`hidden`/`disabled`/
9675
+ // `loading` functions can't round-trip through serialization, so they
9676
+ // get dropped here with a warning. Inline-source configs keep them.
9677
+ if (source === 'json') {
9678
+ const dropReactiveFn = (key) => {
9679
+ if (typeof sanitized[key] === 'function') {
9680
+ ensureCopy();
9681
+ delete sanitized[key];
9682
+ warnings.push({
9683
+ type: 'code-only-action-in-json',
9684
+ fieldKey: field.key,
9685
+ reason: `function '${key}' on kind '${kindDef.kind}' (use a signal/observable in code, or omit in JSON)`,
9686
+ });
9687
+ }
9688
+ };
9689
+ if (typeof sanitized.action === 'function') {
9690
+ ensureCopy();
9691
+ delete sanitized.action;
9692
+ warnings.push({
9693
+ type: 'code-only-action-in-json',
9694
+ fieldKey: field.key,
9695
+ reason: `inline 'action' function on kind '${kindDef.kind}'`,
9696
+ });
9697
+ }
9698
+ dropReactiveFn('hidden');
9699
+ dropReactiveFn('disabled');
9700
+ dropReactiveFn('loading');
9701
+ }
9702
+ // Multi-set XOR — applies to BOTH inline and JSON sources. TypeScript
9703
+ // discriminated unions already reject multi-set at compile time, but
9704
+ // configs that bypass type-checking (loose `any`, runtime composition,
9705
+ // JSON parsing) still need defence-in-depth. Keep the first variant,
9706
+ // strip the rest; mirrors the runtime precedence
9707
+ // (preset > actionRef > action) so the chosen handler matches what
9708
+ // the dispatcher would have picked.
9709
+ if (declaredVariants.length > 1) {
9710
+ ensureCopy();
9711
+ for (const k of declaredVariants.slice(1))
9712
+ delete sanitized[k];
9713
+ warnings.push({
9714
+ type: 'code-only-action-in-json',
9715
+ fieldKey: field.key,
9716
+ reason: `multiple click variants on kind '${kindDef.kind}' (${declaredVariants.join(', ')}); kept '${declaredVariants[0]}'`,
9717
+ });
9718
+ }
9719
+ if (stripped) {
9720
+ survivors.push(sanitized);
9721
+ continue;
9722
+ }
9723
+ // Per-kind shape validator (throws on violation).
9724
+ if (kindDef.validate) {
9725
+ try {
9726
+ kindDef.validate(addon, field.key);
9727
+ }
9728
+ catch (error) {
9729
+ warnings.push({
9730
+ type: 'shape-violation',
9731
+ fieldKey: field.key,
9732
+ kind: kindDef.kind,
9733
+ reason: error instanceof Error ? error.message : String(error),
9734
+ });
9735
+ continue;
9736
+ }
9737
+ }
9738
+ survivors.push(addon);
9739
+ }
9740
+ return { addons: survivors, warnings };
9741
+ }
9742
+ /**
9743
+ * Recursively walk a field tree, validating each field's addons. Returns a
9744
+ * sanitized copy of the input field array plus accumulated warnings.
9745
+ */
9746
+ function walkAndValidateAddons(fields, fieldRegistry, kindRegistry, source) {
9747
+ const allWarnings = [];
9748
+ const sanitized = fields.map((field) => {
9749
+ let next = field;
9750
+ if (field.addons?.length) {
9751
+ const { addons, warnings } = validateFieldAddons(field, fieldRegistry, kindRegistry, source);
9752
+ allWarnings.push(...warnings);
9753
+ next = { ...next, addons };
9754
+ }
9755
+ // Recurse into containers — every container field type carries its
9756
+ // children on a `fields` array.
9757
+ const children = next.fields;
9758
+ if (Array.isArray(children)) {
9759
+ const { fields: visited, warnings } = walkAndValidateAddons(children, fieldRegistry, kindRegistry, source);
9760
+ allWarnings.push(...warnings);
9761
+ next = { ...next, fields: visited };
9762
+ }
9763
+ return next;
9764
+ });
9765
+ return { fields: sanitized, warnings: allWarnings };
9766
+ }
9767
+
9768
+ /**
9769
+ * Stable structural fingerprint for an `AddonWarning`, used by the
9770
+ * FormStateManager dedup cache. Includes every distinguishing field
9771
+ * (`type`, `fieldKey`, `kind`/`slot`/`reason` as relevant) so warnings
9772
+ * for different fields or different kinds never collapse to one log
9773
+ * line — independent of the human-readable format which may change.
9774
+ */
9775
+ function addonWarningKey(w) {
9776
+ switch (w.type) {
9777
+ case 'unknown-field-type':
9778
+ return `${w.type}|${w.fieldKey}|${w.fieldType}`;
9779
+ case 'field-type-no-addon-support':
9780
+ return `${w.type}|${w.fieldKey}|${w.fieldType}`;
9781
+ case 'unknown-slot':
9782
+ return `${w.type}|${w.fieldKey}|${w.fieldType}|${w.slot}`;
9783
+ case 'unknown-kind':
9784
+ return `${w.type}|${w.fieldKey}|${w.kind}`;
9785
+ case 'kind-not-allowed':
9786
+ return `${w.type}|${w.fieldKey}|${w.fieldType}|${w.kind}`;
9787
+ case 'shape-violation':
9788
+ return `${w.type}|${w.fieldKey}|${w.kind}|${w.reason}`;
9789
+ case 'code-only-kind-in-json':
9790
+ return `${w.type}|${w.fieldKey}|${w.kind}`;
9791
+ case 'code-only-action-in-json':
9792
+ return `${w.type}|${w.fieldKey}|${w.reason}`;
9793
+ }
9794
+ }
9795
+ /**
9796
+ * Render an `AddonWarning` to a developer-friendly message.
9797
+ *
9798
+ * Returns the bare message — the `[Dynamic Forms]` prefix is added by the
9799
+ * library's logger (`ConsoleLogger`) and by `DynamicFormError`. Pass the
9800
+ * result of this function directly into `logger.warn(...)` to get the
9801
+ * standard prefixed line; use `logAddonWarnings()` for direct
9802
+ * `console.warn` output (which prefixes manually).
9803
+ *
9804
+ * Messages are designed to be actionable: they enumerate the relevant set
9805
+ * (registered kinds, allowed slots) and suggest a likely fix.
9806
+ */
9807
+ function formatAddonWarning(w) {
9808
+ switch (w.type) {
9809
+ case 'unknown-field-type':
9810
+ return `Field '${w.fieldKey}': unknown field type '${w.fieldType}'. Addons dropped.`;
9811
+ case 'field-type-no-addon-support':
9812
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): this field type does not declare addon support. Addons dropped. ` +
9813
+ `If this is incorrect, add 'addons: { slots: [...] }' to the field type registration.`);
9814
+ case 'unknown-slot':
9815
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): slot '${w.slot}' is not supported. ` +
9816
+ `Allowed: ${w.allowedSlots.join(', ')}. Addon dropped.`);
9817
+ case 'unknown-kind':
9818
+ return (`Field '${w.fieldKey}': addon kind '${w.kind}' is not registered. ` +
9819
+ `Did you forget withCustomAddon({ kind: '${w.kind}', ... })? ` +
9820
+ `Currently registered: ${w.registeredKinds.join(', ') || '(none)'}. Addon dropped.`);
9821
+ case 'kind-not-allowed':
9822
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): addon kind '${w.kind}' is not in the allowed list. ` +
9823
+ `Allowed: ${w.allowedKinds.join(', ')}. Addon dropped.`);
9824
+ case 'shape-violation':
9825
+ return `Field '${w.fieldKey}': addon kind '${w.kind}' shape invalid: ${w.reason}. Addon dropped.`;
9826
+ case 'code-only-kind-in-json':
9827
+ return `Field '${w.fieldKey}': addon kind '${w.kind}' is code-only and cannot be used with JSON-derived configs. Addon dropped.`;
9828
+ case 'code-only-action-in-json':
9829
+ return `Field '${w.fieldKey}': ${w.reason}. Use 'preset' or 'actionRef' instead. Addon dropped.`;
9830
+ }
9831
+ }
9832
+
9833
+ /**
9834
+ * Global registry of addon kinds.
9835
+ *
9836
+ * Populated by `withCustomAddon(...)` features and adapter feature helpers
9837
+ * (e.g., `withPrimeNGAddons()`). Read by `<df-addon-slot>` to resolve a
9838
+ * kind discriminant to its renderer component.
9839
+ */
9840
+ const ADDON_KIND_REGISTRY = new InjectionToken('ADDON_KIND_REGISTRY', {
9841
+ providedIn: 'root',
9842
+ factory: () => new Map(),
9843
+ });
9844
+
9199
9845
  /**
9200
9846
  * Injection token for global value exclusion defaults.
9201
9847
  *
@@ -9241,18 +9887,22 @@ function resolveExclusionConfig(global, form, field) {
9241
9887
  };
9242
9888
  }
9243
9889
  /**
9244
- * Determines whether a field's value should be excluded based on its reactive state
9890
+ * Determines whether a field's value should be excluded based on its reactive state,
9891
+ * the field def's static state (for fields whose `state.hidden()` isn't wired — e.g. groups),
9245
9892
  * and the resolved exclusion config.
9246
9893
  */
9247
- function shouldExcludeField(fieldState, config) {
9894
+ function shouldExcludeField(field, fieldState, config) {
9248
9895
  const state = fieldState();
9249
- if (config.excludeValueIfHidden && state.hidden()) {
9896
+ const isHidden = state.hidden() || field.hidden === true;
9897
+ if (config.excludeValueIfHidden && isHidden) {
9250
9898
  return true;
9251
9899
  }
9252
- if (config.excludeValueIfDisabled && state.disabled()) {
9900
+ const isDisabled = state.disabled() || field.disabled === true;
9901
+ if (config.excludeValueIfDisabled && isDisabled) {
9253
9902
  return true;
9254
9903
  }
9255
- if (config.excludeValueIfReadonly && state.readonly()) {
9904
+ const isReadonly = state.readonly() || field.readonly === true;
9905
+ if (config.excludeValueIfReadonly && isReadonly) {
9256
9906
  return true;
9257
9907
  }
9258
9908
  return false;
@@ -9284,14 +9934,6 @@ function filterFormValue(rawValue, schemaFields, formTree, registry, globalDefau
9284
9934
  // If the key doesn't exist in the raw value, skip
9285
9935
  if (!(key in rawValue))
9286
9936
  continue;
9287
- // Try to access the field state from the form tree
9288
- const fieldState = formTree[key];
9289
- // If no field state is available (e.g., componentless hidden fields),
9290
- // include the value as-is since we can't determine reactive state
9291
- if (!fieldState || typeof fieldState !== 'function') {
9292
- result[key] = rawValue[key];
9293
- continue;
9294
- }
9295
9937
  // Resolve per-field exclusion config
9296
9938
  const fieldExclusion = {
9297
9939
  excludeValueIfHidden: field.excludeValueIfHidden,
@@ -9299,8 +9941,19 @@ function filterFormValue(rawValue, schemaFields, formTree, registry, globalDefau
9299
9941
  excludeValueIfReadonly: field.excludeValueIfReadonly,
9300
9942
  };
9301
9943
  const resolvedConfig = resolveExclusionConfig(globalDefaults, formOptions, fieldExclusion);
9944
+ // Componentless field (no FieldTree) — decide from the static field def alone, then include if not excluded.
9945
+ const fieldState = formTree[key];
9946
+ if (!fieldState || typeof fieldState !== 'function') {
9947
+ const staticallyExcluded = (resolvedConfig.excludeValueIfHidden && field.hidden === true) ||
9948
+ (resolvedConfig.excludeValueIfDisabled && field.disabled === true) ||
9949
+ (resolvedConfig.excludeValueIfReadonly && field.readonly === true);
9950
+ if (!staticallyExcluded) {
9951
+ result[key] = rawValue[key];
9952
+ }
9953
+ continue;
9954
+ }
9302
9955
  // Check if this field should be excluded based on its state
9303
- if (shouldExcludeField(fieldState, resolvedConfig)) {
9956
+ if (shouldExcludeField(field, fieldState, resolvedConfig)) {
9304
9957
  continue;
9305
9958
  }
9306
9959
  // Handle group fields: recurse into nested structure
@@ -9795,6 +10448,77 @@ const FORM_STATE_DEPS = new InjectionToken('FORM_STATE_DEPS');
9795
10448
  function asFieldTreeRecord(tree) {
9796
10449
  return tree;
9797
10450
  }
10451
+ /** All-false baseline for the outward two-way binding filter — see `boundFormValue`. */
10452
+ const BOUND_VALUE_EXCLUSION_BASELINE = {
10453
+ excludeValueIfHidden: false,
10454
+ excludeValueIfDisabled: false,
10455
+ excludeValueIfReadonly: false,
10456
+ };
10457
+ /** True when any of the three exclusion axes is asserted (i.e. the field could be filtered out of the bound model). */
10458
+ function isFieldExcluded(axes) {
10459
+ return !!axes && (axes.hidden || axes.disabled || axes.readonly);
10460
+ }
10461
+ /**
10462
+ * Recursively populates `out` with `dottedPath → exclusion axes` for every keyed field reachable
10463
+ * from `fields`, walking into groups so nested-leaf transitions can be detected. Arrays are
10464
+ * treated as leaves (whole-array filtering only — see value-filter v1 limitation).
10465
+ */
10466
+ function collectFieldStateSnapshot(fields, treeRecord, pathParts, out) {
10467
+ for (const field of fields) {
10468
+ const key = field.key;
10469
+ if (!key)
10470
+ continue;
10471
+ const subtree = treeRecord[key];
10472
+ if (!subtree || typeof subtree !== 'function')
10473
+ continue;
10474
+ const state = subtree();
10475
+ const path = [...pathParts, key];
10476
+ out[path.join('.')] = { hidden: state.hidden(), disabled: state.disabled(), readonly: state.readonly() };
10477
+ if (isGroupField(field) && field.fields) {
10478
+ collectFieldStateSnapshot(field.fields, asFieldTreeRecord(subtree), path, out);
10479
+ }
10480
+ }
10481
+ }
10482
+ /**
10483
+ * Reads a value from a nested object by dotted-path segments. Returns undefined
10484
+ * if any intermediate segment is missing or non-object.
10485
+ */
10486
+ function getValueAtPath(source, segments) {
10487
+ let current = source;
10488
+ for (const segment of segments) {
10489
+ if (current === null || typeof current !== 'object')
10490
+ return undefined;
10491
+ current = current[segment];
10492
+ }
10493
+ return current;
10494
+ }
10495
+ /**
10496
+ * Whether a value is worth capturing into the saved-hidden-value store. `NaN` (the number-input
10497
+ * default) is excluded — restoring it would re-leak the default into the bound model when the
10498
+ * field becomes visible again, defeating the bug fix.
10499
+ */
10500
+ function isRestoreRelevant(value) {
10501
+ return !(typeof value === 'number' && Number.isNaN(value));
10502
+ }
10503
+ /**
10504
+ * Returns a new object with `value` set at the path described by `segments`.
10505
+ * Intermediate objects are cloned shallowly to preserve immutability of `target`.
10506
+ */
10507
+ function setValueAtPath(target, segments, value) {
10508
+ if (segments.length === 0)
10509
+ return target;
10510
+ const [head, ...rest] = segments;
10511
+ const next = { ...target };
10512
+ if (rest.length === 0) {
10513
+ next[head] = value;
10514
+ }
10515
+ else {
10516
+ const child = target[head];
10517
+ const childObj = child !== null && typeof child === 'object' ? child : {};
10518
+ next[head] = setValueAtPath(childObj, rest, value);
10519
+ }
10520
+ return next;
10521
+ }
9798
10522
  /**
9799
10523
  * Central service that manages all form state and coordinates the form lifecycle.
9800
10524
  * Single source of truth for lifecycle state, field resolution, form signals, and events.
@@ -9811,6 +10535,12 @@ class FormStateManager {
9811
10535
  eventBus = inject(EventBus);
9812
10536
  functionRegistry = inject(FunctionRegistryService);
9813
10537
  schemaRegistry = inject(SchemaRegistryService);
10538
+ // Optional so unit-test setups that drive FormStateManager directly without provideDynamicFormDI()
10539
+ // (e.g. mocked RootFormRegistryService specs) don't have to manually wire the registry. Real
10540
+ // DynamicForm usage always provides it via coreProviders; if you hit the dev warning below in
10541
+ // your own setup, you're using DynamicForm without provideDynamicFormDI() — fix the providers
10542
+ // chain rather than ignoring it.
10543
+ arrayItemRegistry = inject(ArrayItemRegistryService, { optional: true });
9814
10544
  /** Host component dependencies (config, formOptions, value). */
9815
10545
  deps = (() => {
9816
10546
  const raw = inject(FORM_STATE_DEPS);
@@ -9819,12 +10549,28 @@ class FormStateManager {
9819
10549
  'Ensure DynamicForm assigns its input signals to FORM_STATE_DEPS.');
9820
10550
  }
9821
10551
  // Safe cast: DynamicForm populates FormStateDeps with its concrete signals.
10552
+ // `source` is optional — `DynamicForm` always populates it, but other
10553
+ // callers of `FormStateManager` (e.g., specs) may omit it.
9822
10554
  return raw;
9823
10555
  })();
9824
10556
  /** Global value exclusion defaults. */
9825
10557
  valueExclusionDefaults = inject(VALUE_EXCLUSION_DEFAULTS);
9826
10558
  /** Field registry for loading components. */
9827
10559
  fieldRegistry = injectFieldRegistry();
10560
+ /** Addon-kind registry — feeds the addon validator at config-setup time. */
10561
+ addonKindRegistry = inject(ADDON_KIND_REGISTRY);
10562
+ /**
10563
+ * Fingerprints of addon warnings emitted by the last config setup.
10564
+ * Used to skip re-logging warnings that already fired for the previous
10565
+ * config — avoids spam when the form swaps config repeatedly but the
10566
+ * addon issues haven't changed.
10567
+ *
10568
+ * Scoping: per-FormStateManager (i.e., per `<df-dynamic-form>` component
10569
+ * instance). Replaced wholesale on every config setup — bounded by the
10570
+ * current config's warning count, never grows unbounded across swaps.
10571
+ * A warning that disappears and later returns re-logs as expected.
10572
+ */
10573
+ lastAddonWarningKeys = new Set();
9828
10574
  // ─────────────────────────────────────────────────────────────────────────────
9829
10575
  // State Machine
9830
10576
  // ─────────────────────────────────────────────────────────────────────────────
@@ -9966,6 +10712,29 @@ class FormStateManager {
9966
10712
  return undefined;
9967
10713
  return new Set(schemaFields.map((f) => f.key).filter((key) => key !== undefined));
9968
10714
  }, ...(ngDevMode ? [{ debugName: "validKeys" }] : /* istanbul ignore next */ []));
10715
+ /**
10716
+ * Per-field exclusion-axis state (hidden/disabled/readonly) keyed by dotted path. Drives the
10717
+ * save-on-exclude effect's transition detection. Walks recursively into groups so nested-leaf
10718
+ * transitions are captured. Arrays are treated as leaves (whole-array preservation only).
10719
+ */
10720
+ fieldStateSnapshot = computed(() => {
10721
+ const setup = this.formSetup();
10722
+ if (!setup.schemaFields || setup.schemaFields.length === 0)
10723
+ return {};
10724
+ const snapshot = {};
10725
+ collectFieldStateSnapshot(setup.schemaFields, asFieldTreeRecord(this.form()), [], snapshot);
10726
+ return snapshot;
10727
+ }, ...(ngDevMode ? [{ debugName: "fieldStateSnapshot" }] : /* istanbul ignore next */ []));
10728
+ /**
10729
+ * Captured values for fields actively excluded from the bound model — either via
10730
+ * `excludeValueIfHidden`, `excludeValueIfDisabled`, or `excludeValueIfReadonly`. Restored when
10731
+ * the field becomes re-included (visible/enabled/editable). The save effect only writes here
10732
+ * when the field is being filtered out by `boundFormValue` — fields without explicit opt-in
10733
+ * never enter the store.
10734
+ */
10735
+ excludedValueStore = signal({}, ...(ngDevMode ? [{ debugName: "excludedValueStore" }] : /* istanbul ignore next */ []));
10736
+ /** Tracks per-path exclusion state across save-effect runs to detect newly-excluded fields. */
10737
+ prevFieldStateSnapshot = signal({}, ...(ngDevMode ? [{ debugName: "prevFieldStateSnapshot" }] : /* istanbul ignore next */ []));
9969
10738
  // ─────────────────────────────────────────────────────────────────────────────
9970
10739
  // Computed Signals - Entity & Form
9971
10740
  // ─────────────────────────────────────────────────────────────────────────────
@@ -9989,10 +10758,13 @@ class FormStateManager {
9989
10758
  const inputValue = this.deps.value();
9990
10759
  const defaults = this.defaultValues();
9991
10760
  const keys = this.validKeys();
10761
+ const saved = this.excludedValueStore();
9992
10762
  // Deep-merge so a partial nested object in `inputValue` (e.g. a group
9993
10763
  // value missing one of its declared sub-field keys) does not orphan
9994
10764
  // the absent sub-field in the Signal Forms validation graph.
9995
- const combined = deepMergeDefaults(defaults, inputValue);
10765
+ // Layer order: defaults < saved (excluded-field restorations) < inputValue.
10766
+ const withSaved = deepMergeDefaults(defaults, saved);
10767
+ const combined = deepMergeDefaults(withSaved, inputValue);
9996
10768
  if (keys) {
9997
10769
  const filtered = {};
9998
10770
  for (const key of Object.keys(combined)) {
@@ -10102,6 +10874,35 @@ class FormStateManager {
10102
10874
  const fieldTreeRecord = asFieldTreeRecord(formTree);
10103
10875
  return filterFormValue(rawValue, setup.schemaFields, fieldTreeRecord, setup.registry, this.valueExclusionDefaults, formOptions);
10104
10876
  }, ...(ngDevMode ? [{ debugName: "filteredFormValue" }] : /* istanbul ignore next */ []));
10877
+ /**
10878
+ * Form value used for the outward two-way binding sync.
10879
+ *
10880
+ * Only honors *explicit* form-level and field-level `excludeValueIf*` settings — global
10881
+ * defaults (`VALUE_EXCLUSION_DEFAULTS`) are intentionally ignored here. Rationale:
10882
+ * - Original V1 behavior: the bound model carries all values regardless of visibility.
10883
+ * - Bug fix: when a user explicitly sets `excludeValueIfHidden: true` on a field/form,
10884
+ * they expect that value to disappear from the bound model when hidden (e.g. NaN for
10885
+ * number inputs, see issue #394).
10886
+ * - Without explicit opt-in, the bound model keeps the V1 contract.
10887
+ * `filteredFormValue` (above) still applies the full Field > Form > Global hierarchy
10888
+ * for the submission output.
10889
+ */
10890
+ boundFormValue = computed(() => {
10891
+ const rawValue = this.formValue();
10892
+ const setup = this.formSetup();
10893
+ const options = this.effectiveFormOptions();
10894
+ if (!setup.schemaFields || setup.schemaFields.length === 0) {
10895
+ return rawValue;
10896
+ }
10897
+ const formTree = this.form();
10898
+ const formOptions = {
10899
+ excludeValueIfHidden: options.excludeValueIfHidden,
10900
+ excludeValueIfDisabled: options.excludeValueIfDisabled,
10901
+ excludeValueIfReadonly: options.excludeValueIfReadonly,
10902
+ };
10903
+ const fieldTreeRecord = asFieldTreeRecord(formTree);
10904
+ return filterFormValue(rawValue, setup.schemaFields, fieldTreeRecord, setup.registry, BOUND_VALUE_EXCLUSION_BASELINE, formOptions);
10905
+ }, ...(ngDevMode ? [{ debugName: "boundFormValue" }] : /* istanbul ignore next */ []));
10105
10906
  /** Whether the form is currently valid. */
10106
10907
  valid = computed(() => this.formInstance().valid(), ...(ngDevMode ? [{ debugName: "valid" }] : /* istanbul ignore next */ []));
10107
10908
  /** Whether the form is currently invalid. */
@@ -10318,6 +11119,7 @@ class FormStateManager {
10318
11119
  * Resets the form to default values.
10319
11120
  */
10320
11121
  reset() {
11122
+ this.excludedValueStore.set({});
10321
11123
  const defaults = this.defaultValues();
10322
11124
  this.form()().value.set(defaults);
10323
11125
  this.deps.value.set(defaults);
@@ -10326,6 +11128,7 @@ class FormStateManager {
10326
11128
  * Clears the form to empty state.
10327
11129
  */
10328
11130
  clear() {
11131
+ this.excludedValueStore.set({});
10329
11132
  const emptyValue = {};
10330
11133
  this.form()().value.set(emptyValue);
10331
11134
  this.deps.value.set(emptyValue);
@@ -10350,12 +11153,61 @@ class FormStateManager {
10350
11153
  this.machine.dispatch({ type: Action.ConfigChange, config });
10351
11154
  }
10352
11155
  });
10353
- // Outward sync: write entity changes back to deps.value (see entity JSDoc for full explanation).
10354
- // The isEqual guard prevents infinite ping-pong between this effect and the linkedSignal source.
10355
- explicitEffect([this.entity], ([currentEntity]) => {
11156
+ // Save-on-exclude: capture entity values for fields that boundFormValue is actively
11157
+ // filtering out via any of the three axes (hidden / disabled / readonly), so they can be
11158
+ // restored when the field becomes re-included. The transition fires once per "no axis
11159
+ // asserted → at least one axis asserted" edge; subsequent axis changes while still excluded
11160
+ // are no-ops. Comparing entity vs bound at the path limits the store to fields that are
11161
+ // actually being filtered — fields without explicit opt-in never enter the store and never
11162
+ // participate in the entity merge. `NaN` values are skipped: they're the number-input default
11163
+ // and would re-leak via the merge once the field becomes re-included.
11164
+ explicitEffect([this.fieldStateSnapshot], ([snapshot]) => {
11165
+ const currentSaved = this.excludedValueStore();
11166
+ const currentEntity = this.entity();
11167
+ const boundValue = this.boundFormValue();
11168
+ const prev = this.prevFieldStateSnapshot();
11169
+ let newSaved = currentSaved;
11170
+ for (const [path, axes] of Object.entries(snapshot)) {
11171
+ if (isFieldExcluded(axes) && !isFieldExcluded(prev[path])) {
11172
+ const segments = path.split('.');
11173
+ const inEntity = getValueAtPath(currentEntity, segments);
11174
+ const inBound = getValueAtPath(boundValue, segments);
11175
+ if (inEntity !== undefined && inBound === undefined && isRestoreRelevant(inEntity)) {
11176
+ newSaved = setValueAtPath(newSaved, segments, inEntity);
11177
+ }
11178
+ }
11179
+ }
11180
+ this.prevFieldStateSnapshot.set({ ...snapshot });
11181
+ if (newSaved !== currentSaved && !isEqual(newSaved, currentSaved)) {
11182
+ this.excludedValueStore.set(newSaved);
11183
+ }
11184
+ });
11185
+ // Schema change resets the saved store + transition tracker so values from a stale schema
11186
+ // can't leak into the new one. Also drops every per-array slot in the registry so a fresh
11187
+ // schema starts with an empty template/itemOrder/id-counter state.
11188
+ let warnedAboutMissingArrayRegistry = false;
11189
+ explicitEffect([this.formSetup], () => {
11190
+ this.prevFieldStateSnapshot.set({});
11191
+ if (Object.keys(this.excludedValueStore()).length > 0) {
11192
+ this.excludedValueStore.set({});
11193
+ }
11194
+ if (this.arrayItemRegistry) {
11195
+ this.arrayItemRegistry.clearAll();
11196
+ }
11197
+ else if (DEV_MODE && !warnedAboutMissingArrayRegistry) {
11198
+ warnedAboutMissingArrayRegistry = true;
11199
+ this.logger.warn('ArrayItemRegistryService was not provided to FormStateManager. ' +
11200
+ 'Array fields will lose dynamically-added item templates across hide/show cycles. ' +
11201
+ 'This usually means DynamicForm was bootstrapped without provideDynamicFormDI() — ' +
11202
+ 'check your providers chain.');
11203
+ }
11204
+ });
11205
+ // Outward sync uses boundFormValue (honors only explicit excludeValueIf* opt-ins) so
11206
+ // global defaults that target submission output don't reshape the host's bound model.
11207
+ explicitEffect([this.boundFormValue], ([currentBound]) => {
10356
11208
  const currentValue = this.deps.value();
10357
- if (!isEqual(currentEntity, currentValue)) {
10358
- this.deps.value.set(currentEntity);
11209
+ if (!isEqual(currentBound, currentValue)) {
11210
+ this.deps.value.set(currentBound);
10359
11211
  }
10360
11212
  });
10361
11213
  explicitEffect([this.formConfigValidation], ([validation]) => {
@@ -10406,11 +11258,35 @@ class FormStateManager {
10406
11258
  }
10407
11259
  createFormSetupFromConfig(fields, mode, registry) {
10408
11260
  const normalizedFields = normalizeSimplifiedArrays(fields);
10409
- // Validate after normalization so simplified array templates are already expanded
10410
- // into full ArrayField.fields and are reachable during traversal.
11261
+ // Two validation passes run after normalization so simplified array templates
11262
+ // are already expanded into full ArrayField.fields and reachable during traversal:
11263
+ // 1. Structural — duplicate keys, unknown field types, invalid regex patterns.
11264
+ // 2. Addon pass below — strips invalid/unsupported addons with lenient warnings.
10411
11265
  validateFormConfig(normalizedFields, registry, this.logger);
10412
- const flattenedFields = this.fieldProcessors.memoizedFlattenFields(normalizedFields, registry);
10413
- const flattenedFieldsForRendering = this.fieldProcessors.memoizedFlattenFieldsForRendering(normalizedFields, registry);
11266
+ // Addon pass — strip invalid / unsupported addon entries and log a warning
11267
+ // for each one dropped. Lenient by design: the form keeps rendering even
11268
+ // when a JSON-source config ships an addon the FE doesn't understand.
11269
+ //
11270
+ // Warnings are deduped across consecutive config setups: a warning that
11271
+ // already fired for the previous config is skipped, so swapping config
11272
+ // back and forth doesn't spam the console.
11273
+ const source = this.deps.source?.() ?? 'inline';
11274
+ const { fields: sanitizedFields, warnings: addonWarnings } = walkAndValidateAddons(normalizedFields, registry, this.addonKindRegistry, source);
11275
+ const nextKeys = new Set();
11276
+ for (const w of addonWarnings) {
11277
+ // Dedup uses a structural fingerprint so different fields with the
11278
+ // same warning category never collide; the rendered message is what
11279
+ // we log.
11280
+ const key = addonWarningKey(w);
11281
+ nextKeys.add(key);
11282
+ if (!this.lastAddonWarningKeys.has(key)) {
11283
+ this.logger.warn(formatAddonWarning(w));
11284
+ }
11285
+ }
11286
+ this.lastAddonWarningKeys = nextKeys;
11287
+ const validatedFields = sanitizedFields;
11288
+ const flattenedFields = this.fieldProcessors.memoizedFlattenFields(validatedFields, registry);
11289
+ const flattenedFieldsForRendering = this.fieldProcessors.memoizedFlattenFieldsForRendering(validatedFields, registry);
10414
11290
  const fieldsById = this.fieldProcessors.memoizedKeyBy(flattenedFields);
10415
11291
  const defaultValues = this.fieldProcessors.memoizedDefaultValues(fieldsById, registry);
10416
11292
  const fieldsToRender = mode === 'paged' ? [] : flattenedFieldsForRendering;
@@ -10438,10 +11314,10 @@ class FormStateManager {
10438
11314
  ngOnDestroy() {
10439
11315
  this.machine.dispatch({ type: Action.Destroy });
10440
11316
  }
10441
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
10442
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager });
11317
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
11318
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager });
10443
11319
  }
10444
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager, decorators: [{
11320
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager, decorators: [{
10445
11321
  type: Injectable
10446
11322
  }], ctorParameters: () => [] });
10447
11323
 
@@ -10860,15 +11736,18 @@ function computeValueFromEntry(entry, evalContext, options) {
10860
11736
  if (entry.expression) {
10861
11737
  return ExpressionParser.evaluate(entry.expression, evalContext);
10862
11738
  }
10863
- if (entry.functionName) {
10864
- const fn = options.derivationFunctions?.[entry.functionName];
11739
+ if (entry.fn || entry.functionName) {
11740
+ if (entry.fn && entry.functionName) {
11741
+ evalContext.logger.warn(`Both "fn" and "functionName" are set on derivation for '${options.subject}'. Inline "fn" takes precedence; "functionName" is ignored.`);
11742
+ }
11743
+ const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);
10865
11744
  if (!fn) {
10866
11745
  const kind = options.functionKind ?? 'Derivation function';
10867
11746
  throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);
10868
11747
  }
10869
11748
  return fn(evalContext);
10870
11749
  }
10871
- throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', or 'functionName'.`);
11750
+ throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`);
10872
11751
  }
10873
11752
 
10874
11753
  /**
@@ -11271,7 +12150,7 @@ function getEntriesForChangedFields$1(entries, changedFields) {
11271
12150
  function tryApplyDerivation(entry, context, chainContext) {
11272
12151
  const { derivationLogger } = context;
11273
12152
  // HTTP and async entries are processed asynchronously in their own streams — skip in sync loop
11274
- if (entry.http || entry.asyncFunctionName) {
12153
+ if (entry.http || entry.asyncFunctionName || entry.asyncFn) {
11275
12154
  return { applied: false, fieldKey: entry.fieldKey };
11276
12155
  }
11277
12156
  // Check if this is an array field derivation (has '$' placeholder)
@@ -11578,9 +12457,11 @@ function formatDerivationError(entry, phase, message) {
11578
12457
  ? `expression: "${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}"`
11579
12458
  : entry.functionName
11580
12459
  ? `function: "${entry.functionName}"`
11581
- : entry.value !== undefined
11582
- ? 'static value'
11583
- : 'unknown source';
12460
+ : entry.fn
12461
+ ? 'inline fn'
12462
+ : entry.value !== undefined
12463
+ ? 'static value'
12464
+ : 'unknown source';
11584
12465
  return `${ERROR_PREFIX$1} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;
11585
12466
  }
11586
12467
  /**
@@ -11649,7 +12530,7 @@ function shouldSkipForUserOverride(entry, resolvedFieldKey, context, chainContex
11649
12530
  function applyDerivationsForTrigger(collection, trigger, context, changedFields) {
11650
12531
  // Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)
11651
12532
  const filteredEntries = collection.entries.filter((entry) => {
11652
- if (entry.http || entry.asyncFunctionName)
12533
+ if (entry.http || entry.asyncFunctionName || entry.asyncFn)
11653
12534
  return false;
11654
12535
  if (trigger === 'onChange') {
11655
12536
  return !entry.trigger || entry.trigger === 'onChange';
@@ -11818,7 +12699,7 @@ function topologicalSort(entries) {
11818
12699
  * Combines:
11819
12700
  * - Explicit `dependsOn` (when provided, it takes precedence over auto-detection)
11820
12701
  * - Expression-detected dependencies (only used when `dependsOn` is empty)
11821
- * - `'*'` wildcard for `functionName` (only when `dependsOn` is empty)
12702
+ * - `'*'` wildcard for `functionName` / inline `fn` (only when `dependsOn` is empty)
11822
12703
  * - Condition expression dependencies (always included)
11823
12704
  *
11824
12705
  * @internal
@@ -11832,7 +12713,9 @@ function extractDependenciesFromConfig(config) {
11832
12713
  if (config.expression) {
11833
12714
  extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));
11834
12715
  }
11835
- if (config.functionName) {
12716
+ // Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,
12717
+ // assume the function may depend on anything in the form.
12718
+ if (config.functionName || config.fn) {
11836
12719
  deps.add('*');
11837
12720
  }
11838
12721
  }
@@ -11842,6 +12725,32 @@ function extractDependenciesFromConfig(config) {
11842
12725
  return Array.from(deps);
11843
12726
  }
11844
12727
 
12728
+ /**
12729
+ * Reads the optional inline `fn` property off a derivation config.
12730
+ *
12731
+ * `fn` only appears on the function-mode branch of the discriminated union, so
12732
+ * call sites that need to copy it through unconditionally must widen via cast.
12733
+ * This helper centralises the cast and keeps it read-only — no widening, no
12734
+ * mutation. Returns `undefined` for any non-function-mode variant.
12735
+ *
12736
+ * @internal
12737
+ */
12738
+ function extractInlineFn(config) {
12739
+ return config.fn;
12740
+ }
12741
+ /**
12742
+ * Reads the optional inline `asyncFn` property off a derivation config.
12743
+ *
12744
+ * Same rationale as {@link extractInlineFn}: `asyncFn` only appears on the
12745
+ * `source: 'asyncFunction'` branch, so collectors that copy it through need a
12746
+ * cast. Read-only access, no widening.
12747
+ *
12748
+ * @internal
12749
+ */
12750
+ function extractInlineAsyncFn(config) {
12751
+ return config.asyncFn;
12752
+ }
12753
+
11845
12754
  /**
11846
12755
  * Recursively walks a field-definition tree, invoking `visitor` for each field.
11847
12756
  *
@@ -12184,9 +13093,11 @@ function createLogicEntry(fieldKey, config, context) {
12184
13093
  value: config.value,
12185
13094
  expression: config.expression,
12186
13095
  functionName: config.functionName,
13096
+ fn: extractInlineFn(config),
12187
13097
  http: config.http,
12188
13098
  responseExpression: config.responseExpression,
12189
13099
  asyncFunctionName: config.asyncFunctionName,
13100
+ asyncFn: extractInlineAsyncFn(config),
12190
13101
  trigger,
12191
13102
  debounceMs,
12192
13103
  isShorthand: false,
@@ -12735,9 +13646,16 @@ function createAsyncDerivationStream(entry, formValue$, context) {
12735
13646
  `Array async derivations will be supported in a future release.`);
12736
13647
  return EMPTY;
12737
13648
  }
12738
- if (!entry.asyncFunctionName) {
13649
+ if (!entry.asyncFunctionName && !entry.asyncFn) {
13650
+ context.logger.warn(`${LOG_PREFIX} Derivation for '${entry.fieldKey}' has neither "asyncFunctionName" nor "asyncFn". ` +
13651
+ `One of the two is required for source: 'asyncFunction'.`);
12739
13652
  return EMPTY;
12740
13653
  }
13654
+ if (entry.asyncFn && entry.asyncFunctionName) {
13655
+ context.logger.warn(`${LOG_PREFIX} Both "asyncFn" and "asyncFunctionName" are set on derivation for '${entry.fieldKey}'. ` +
13656
+ `Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.`);
13657
+ }
13658
+ const inlineAsyncFn = entry.asyncFn;
12741
13659
  const asyncFunctionName = entry.asyncFunctionName;
12742
13660
  const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;
12743
13661
  // startWith(null) → pairwise() causes all fields to appear "changed" on the
@@ -12783,8 +13701,8 @@ function createAsyncDerivationStream(entry, formValue$, context) {
12783
13701
  const customFunctions = context.customFunctions?.();
12784
13702
  const asyncDerivationFunctions = context.asyncDerivationFunctions?.();
12785
13703
  const externalData = context.externalData?.();
12786
- // Look up the async function
12787
- const asyncFn = asyncDerivationFunctions?.[asyncFunctionName];
13704
+ // Inline `asyncFn` wins; otherwise look up the registered function.
13705
+ const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);
12788
13706
  if (!asyncFn) {
12789
13707
  context.logger.warn(`${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +
12790
13708
  `Register it in customFnConfig.asyncDerivations.`);
@@ -13212,7 +14130,7 @@ class DerivationOrchestrator {
13212
14130
  toObservable(this.derivationCollection, { injector: this.injector })
13213
14131
  .pipe(takeUntilDestroyed(this.destroyRef))
13214
14132
  .subscribe((collection) => {
13215
- const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName) ?? [];
14133
+ const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [];
13216
14134
  if (asyncEntries.length === 0) {
13217
14135
  this.teardownAsyncFunctionStreams();
13218
14136
  this.lastAsyncEntryKeys = null;
@@ -13259,9 +14177,14 @@ class DerivationOrchestrator {
13259
14177
  * Used for smart teardown comparison.
13260
14178
  */
13261
14179
  computeAsyncEntryKeys(entries) {
14180
+ // For inline `asyncFn` entries we tag the key with the field path (the real
14181
+ // identity, since derivations are self-targeting) rather than the array
14182
+ // index. Index-based tagging churned identities whenever topologicalSort
14183
+ // reordered entries, causing unnecessary teardown + re-subscribe.
13262
14184
  return new Set(entries.map((entry) => {
13263
14185
  const config = {
13264
14186
  asyncFunctionName: entry.asyncFunctionName,
14187
+ asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,
13265
14188
  dependsOn: entry.dependsOn,
13266
14189
  debounceMs: entry.debounceMs,
13267
14190
  stopOnUserOverride: entry.stopOnUserOverride,
@@ -13310,10 +14233,11 @@ function warnAboutWildcardDependencies$1(logger, entries, fieldCount) {
13310
14233
  // HTTP and async entries require explicit dependsOn (validated at collection time)
13311
14234
  const implicitWildcards = wildcardEntries.filter((entry) => !entry.http &&
13312
14235
  !entry.asyncFunctionName &&
13313
- entry.functionName &&
14236
+ !entry.asyncFn &&
14237
+ (entry.functionName || entry.fn) &&
13314
14238
  (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
13315
14239
  if (implicitWildcards.length > 0) {
13316
- const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName})`);
14240
+ const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);
13317
14241
  logger.warn('Derivation: custom functions without explicit dependsOn detected. ' +
13318
14242
  `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
13319
14243
  'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
@@ -13477,7 +14401,11 @@ function isDynamicFormFeature(value) {
13477
14401
  Array.isArray(value.ɵproviders));
13478
14402
  }
13479
14403
  /**
13480
- * Helper to create a feature with proper typing
14404
+ * Helper to create a feature with proper typing.
14405
+ *
14406
+ * `kind` is the discriminant compared by string equality at the
14407
+ * provider-resolution site (and in feature-specific type guards). Each
14408
+ * feature module owns its own kind literal — no central registry.
13481
14409
  */
13482
14410
  function createFeature(kind, providers) {
13483
14411
  return {
@@ -13796,8 +14724,16 @@ function collectPropertyDerivations(fields, logger, tracker) {
13796
14724
  const entries = [];
13797
14725
  const context = { logger, tracker };
13798
14726
  traverseFieldsWithContext(fields, context, (field, ctx) => collectFromField(field, entries, ctx), {
13799
- onArrayChild: (_, field) => ({ arrayPath: field.key }),
13800
- // Group and layout containers do not affect property-derivation paths.
14727
+ // Array boundary: each item is its own model root. Reset groupPath so
14728
+ // ancestor groups OUTSIDE the array don't bleed into per-item keys.
14729
+ onArrayChild: (_, field) => ({ arrayPath: field.key, groupPath: undefined }),
14730
+ onGroupChild: (parent, field) => {
14731
+ // Keyless groups (rare) act like layout containers — pass through.
14732
+ if (!field.key)
14733
+ return {};
14734
+ return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };
14735
+ },
14736
+ // Layout containers (page, row, container) leave context unchanged.
13801
14737
  });
13802
14738
  return { entries };
13803
14739
  }
@@ -13829,7 +14765,7 @@ function collectFromField(field, entries, context) {
13829
14765
  * @internal
13830
14766
  */
13831
14767
  function createPropertyDerivationEntryFromDerivation(fieldKey, config, context) {
13832
- const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey);
14768
+ const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey, context.groupPath);
13833
14769
  const dependsOn = extractDependenciesFromConfig(config);
13834
14770
  const condition = config.condition ?? true;
13835
14771
  const trigger = config.trigger ?? 'onChange';
@@ -13842,6 +14778,7 @@ function createPropertyDerivationEntryFromDerivation(fieldKey, config, context)
13842
14778
  value: config.value,
13843
14779
  expression: config.expression,
13844
14780
  functionName: config.functionName,
14781
+ fn: extractInlineFn(config),
13845
14782
  trigger,
13846
14783
  debounceMs,
13847
14784
  debugName: config.debugName,
@@ -13996,16 +14933,40 @@ const PROPERTY_DERIVATION_ORCHESTRATOR = new InjectionToken('PROPERTY_DERIVATION
13996
14933
  // ─────────────────────────────────────────────────────────────────────────────
13997
14934
  function warnAboutWildcardDependencies(logger, entries, fieldCount) {
13998
14935
  const implicitWildcards = entries.filter((entry) => entry.dependsOn.includes('*') &&
13999
- entry.functionName &&
14936
+ (entry.functionName || entry.fn) &&
14000
14937
  (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
14001
14938
  if (implicitWildcards.length > 0) {
14002
- const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName})`);
14939
+ const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);
14003
14940
  logger.warn('PropertyDerivation: custom functions without explicit dependsOn detected. ' +
14004
14941
  `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
14005
14942
  'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
14006
14943
  }
14007
14944
  }
14008
14945
 
14946
+ /**
14947
+ * Multi-provider token populated by `provideAddonActions({...})` calls.
14948
+ * `ADDON_ACTION_REGISTRY` aggregates these into a single name → handler map.
14949
+ *
14950
+ * @internal
14951
+ */
14952
+ const ADDON_ACTION_HANDLERS = new InjectionToken('ADDON_ACTION_HANDLERS');
14953
+ /**
14954
+ * Form-scoped name → handler map. Read by adapter button kinds at click
14955
+ * time for `actionRef: 'name'` lookups. Provided at form scope (not root)
14956
+ * because `ADDON_ACTION_HANDLERS` is form-scoped via `provideAddonActions`.
14957
+ */
14958
+ const ADDON_ACTION_REGISTRY = new InjectionToken('ADDON_ACTION_REGISTRY');
14959
+ function createAddonActionRegistry() {
14960
+ const sources = inject(ADDON_ACTION_HANDLERS, { optional: true }) ?? [];
14961
+ const map = new Map();
14962
+ for (const handlers of sources) {
14963
+ for (const [name, fn] of Object.entries(handlers)) {
14964
+ map.set(name, fn);
14965
+ }
14966
+ }
14967
+ return map;
14968
+ }
14969
+
14009
14970
  /**
14010
14971
  * Always-on providers for any DynamicForm: state machine, registries, signal-context tokens.
14011
14972
  * Pure form bootstrap — no derivation, HTTP, or async machinery.
@@ -14020,7 +14981,7 @@ function coreProviders() {
14020
14981
  FunctionRegistryService,
14021
14982
  {
14022
14983
  provide: FORM_STATE_DEPS,
14023
- useFactory: () => ({ config: null, formOptions: null, value: null }),
14984
+ useFactory: () => ({ config: null, formOptions: null, value: null, source: null }),
14024
14985
  },
14025
14986
  FormStateManager,
14026
14987
  {
@@ -14029,6 +14990,7 @@ function coreProviders() {
14029
14990
  deps: [FormStateManager],
14030
14991
  },
14031
14992
  FieldContextRegistryService,
14993
+ ArrayItemRegistryService,
14032
14994
  {
14033
14995
  provide: DEFAULT_PROPS,
14034
14996
  useFactory: (stateManager) => computed(() => stateManager.activeConfig()?.defaultProps),
@@ -14055,6 +15017,11 @@ function coreProviders() {
14055
15017
  deps: [FormStateManager],
14056
15018
  },
14057
15019
  { provide: DEPRECATION_WARNING_TRACKER, useFactory: createWarningTracker },
15020
+ // ADDON_ACTION_REGISTRY must live at form scope (not root) because the
15021
+ // multi-provider source `ADDON_ACTION_HANDLERS` is contributed by
15022
+ // `provideAddonActions(...)` features passed to `provideDynamicForm(...)`
15023
+ // — those entries are only visible inside the form's own injector tree.
15024
+ { provide: ADDON_ACTION_REGISTRY, useFactory: createAddonActionRegistry },
14058
15025
  ];
14059
15026
  }
14060
15027
  /**
@@ -14246,13 +15213,90 @@ class EventDispatcher {
14246
15213
  disconnect() {
14247
15214
  this.bus = undefined;
14248
15215
  }
14249
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
14250
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher });
15216
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
15217
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher });
15218
+ }
15219
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher, decorators: [{
15220
+ type: Injectable
15221
+ }] });
15222
+
15223
+ /**
15224
+ * Marks an `<ng-template>` projected into `<df-dynamic-form>` content as
15225
+ * available to template addons by name.
15226
+ *
15227
+ * The host form collects all `DfTemplate` instances via `contentChildren`
15228
+ * and exposes them through {@link DF_FIELD_TEMPLATES}, where the template
15229
+ * addon kind looks them up by `templateKey` at render time.
15230
+ *
15231
+ * @example
15232
+ * ```html
15233
+ * <df-dynamic-form [config]="config">
15234
+ * <ng-template dfTemplate="searchIcon" let-field>
15235
+ * <my-icon [field]="field" />
15236
+ * </ng-template>
15237
+ * </df-dynamic-form>
15238
+ * ```
15239
+ *
15240
+ * In the field config:
15241
+ * ```ts
15242
+ * { slot: 'prefix', kind: 'template', templateKey: 'searchIcon' }
15243
+ * ```
15244
+ */
15245
+ class DfTemplate {
15246
+ /** Name this template registers under — referenced from `TemplateAddon.templateKey`. */
15247
+ dfTemplate = input.required(...(ngDevMode ? [{ debugName: "dfTemplate" }] : /* istanbul ignore next */ []));
15248
+ /** The wrapped template, captured for registration and `NgTemplateOutlet` rendering. */
15249
+ templateRef = inject(TemplateRef);
15250
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
15251
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.14", type: DfTemplate, isStandalone: true, selector: "ng-template[dfTemplate]", inputs: { dfTemplate: { classPropertyName: "dfTemplate", publicName: "dfTemplate", isSignal: true, isRequired: true, transformFunction: null } }, exportAs: ["dfTemplate"], ngImport: i0 });
14251
15252
  }
14252
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher, decorators: [{
15253
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfTemplate, decorators: [{
15254
+ type: Directive,
15255
+ args: [{
15256
+ selector: 'ng-template[dfTemplate]',
15257
+ exportAs: 'dfTemplate',
15258
+ }]
15259
+ }], propDecorators: { dfTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "dfTemplate", required: true }] }] } });
15260
+ /**
15261
+ * Holder service published at the `<df-dynamic-form>` component scope.
15262
+ * `DynamicForm` collects projected `<ng-template dfTemplate="...">` via
15263
+ * `contentChildren(DfTemplate)` and pushes the resulting map through
15264
+ * `DfFieldTemplateRegistry.set(...)`. The {@link DF_FIELD_TEMPLATES}
15265
+ * injection token is provided as a factory that returns this service's
15266
+ * `map` signal, so template-addon components can resolve `templateKey`
15267
+ * without coupling to the host form's generic class.
15268
+ */
15269
+ class DfFieldTemplateRegistry {
15270
+ _map = signal(new Map(), ...(ngDevMode ? [{ debugName: "_map" }] : /* istanbul ignore next */ []));
15271
+ /** Reactive view consumed by the `template` addon kind via DI. */
15272
+ map = this._map.asReadonly();
15273
+ /** Replace the registry contents — called from `DynamicForm` when projected templates change. */
15274
+ set(value) {
15275
+ this._map.set(value);
15276
+ }
15277
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
15278
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry });
15279
+ }
15280
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry, decorators: [{
14253
15281
  type: Injectable
14254
15282
  }] });
14255
15283
 
15284
+ /**
15285
+ * Map of named `TemplateRef`s collected by `<df-dynamic-form>` from
15286
+ * `<ng-template dfTemplate="...">` children projected into its content.
15287
+ * The directive selector is `dfTemplate` (see `DfTemplate`); the input
15288
+ * value is the name addons reference via `templateKey`.
15289
+ *
15290
+ * The `template` addon kind looks up its `templateKey` in this map at
15291
+ * render time. Exposed as a `Signal` so projections can be added or
15292
+ * removed reactively (e.g., when the host conditionally projects).
15293
+ *
15294
+ * Provided at the form-component level (not root) so multiple
15295
+ * `<df-dynamic-form>` instances on the same page have independent
15296
+ * template scopes.
15297
+ */
15298
+ const DF_FIELD_TEMPLATES = new InjectionToken('DF_FIELD_TEMPLATES');
15299
+
14256
15300
  /**
14257
15301
  * Dynamic form component — renders a form based on configuration.
14258
15302
  * Delegates state management to `FormStateManager`.
@@ -14272,6 +15316,13 @@ class DynamicForm {
14272
15316
  formOptions = input(undefined, ...(ngDevMode ? [{ debugName: "formOptions" }] : /* istanbul ignore next */ []));
14273
15317
  /** Form values for two-way data binding. */
14274
15318
  value = model(undefined, ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
15319
+ /**
15320
+ * How lenient the addon validator is on `config.fields[...].addons`.
15321
+ * `'inline'` (default) keeps everything; `'json'` drops code-only kinds
15322
+ * with a warning. Read at form init and on every config swap — change
15323
+ * `source` AND the config to force re-validation mid-flight.
15324
+ */
15325
+ source = input('inline', ...(ngDevMode ? [{ debugName: "source" }] : /* istanbul ignore next */ []));
14275
15326
  // ─────────────────────────────────────────────────────────────────────────────
14276
15327
  // Dependencies
14277
15328
  // ─────────────────────────────────────────────────────────────────────────────
@@ -14281,6 +15332,12 @@ class DynamicForm {
14281
15332
  eventBus = inject(EventBus);
14282
15333
  logger = inject(DynamicFormLogger);
14283
15334
  dispatcher = inject(EventDispatcher, { optional: true });
15335
+ templateRegistry = inject(DfFieldTemplateRegistry);
15336
+ // ─────────────────────────────────────────────────────────────────────────────
15337
+ // Projected templates — collected for the `template` addon kind via DI.
15338
+ // ─────────────────────────────────────────────────────────────────────────────
15339
+ /** All `<ng-template dfTemplate="...">` instances projected into this form. */
15340
+ _projectedTemplates = contentChildren(DfTemplate, ...(ngDevMode ? [{ debugName: "_projectedTemplates" }] : /* istanbul ignore next */ []));
14284
15341
  /** State manager that owns all form state. Initialized via connectDeps() to guarantee
14285
15342
  * FORM_STATE_DEPS is populated before FormStateManager is injected. */
14286
15343
  stateManager = this.connectDeps();
@@ -14433,6 +15490,15 @@ class DynamicForm {
14433
15490
  }, { injector: this.injector });
14434
15491
  }
14435
15492
  });
15493
+ // Mirror projected `<ng-template dfTemplate="...">` into the template
15494
+ // registry so `template` addons can resolve `templateKey` via DI.
15495
+ explicitEffect([this._projectedTemplates], ([templates]) => {
15496
+ const map = new Map();
15497
+ for (const t of templates) {
15498
+ map.set(t.dfTemplate(), t.templateRef);
15499
+ }
15500
+ this.templateRegistry.set(map);
15501
+ });
14436
15502
  }
14437
15503
  /**
14438
15504
  * Populates FORM_STATE_DEPS with this component's input signals, then injects
@@ -14445,6 +15511,7 @@ class DynamicForm {
14445
15511
  deps.config = this.config;
14446
15512
  deps.formOptions = this.formOptions;
14447
15513
  deps.value = this.value;
15514
+ deps.source = this.source;
14448
15515
  return inject((FormStateManager));
14449
15516
  }
14450
15517
  setupEventHandlers() {
@@ -14460,8 +15527,12 @@ class DynamicForm {
14460
15527
  error: (err) => this.logger.error('Submission handler error', err),
14461
15528
  });
14462
15529
  }
14463
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
14464
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: DynamicForm, isStandalone: true, selector: "form[dynamic-form]", inputs: { config: { classPropertyName: "config", publicName: "dynamic-form", isSignal: true, isRequired: true, transformFunction: null }, formOptions: { classPropertyName: "formOptions", publicName: "formOptions", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", validityChange: "validityChange", dirtyChange: "dirtyChange", submitted: "submitted", reset: "reset", cleared: "cleared", events: "events", initialized: "initialized", onPageChange: "onPageChange", onPageNavigationStateChange: "onPageNavigationStateChange" }, host: { attributes: { "novalidate": "" }, listeners: { "submit": "onNativeSubmit($event)" }, properties: { "class.disabled": "disabled()", "class.df-form-paged": "formModeDetection().mode === \"paged\"", "class.df-form-non-paged": "formModeDetection().mode === \"non-paged\"", "attr.data-form-mode": "formModeDetection().mode" }, classAttribute: "df-dynamic-form df-form" }, providers: [provideDynamicFormDI()], ngImport: i0, template: `
15530
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
15531
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: DynamicForm, isStandalone: true, selector: "form[dynamic-form]", inputs: { config: { classPropertyName: "config", publicName: "dynamic-form", isSignal: true, isRequired: true, transformFunction: null }, formOptions: { classPropertyName: "formOptions", publicName: "formOptions", isSignal: true, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: true, isRequired: false, transformFunction: null }, source: { classPropertyName: "source", publicName: "source", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { value: "valueChange", validityChange: "validityChange", dirtyChange: "dirtyChange", submitted: "submitted", reset: "reset", cleared: "cleared", events: "events", initialized: "initialized", onPageChange: "onPageChange", onPageNavigationStateChange: "onPageNavigationStateChange" }, host: { attributes: { "novalidate": "" }, listeners: { "submit": "onNativeSubmit($event)" }, properties: { "class.disabled": "disabled()", "class.df-form-paged": "formModeDetection().mode === \"paged\"", "class.df-form-non-paged": "formModeDetection().mode === \"non-paged\"", "attr.data-form-mode": "formModeDetection().mode" }, classAttribute: "df-dynamic-form df-form" }, providers: [
15532
+ provideDynamicFormDI(),
15533
+ DfFieldTemplateRegistry,
15534
+ { provide: DF_FIELD_TEMPLATES, useFactory: () => inject(DfFieldTemplateRegistry).map },
15535
+ ], queries: [{ propertyName: "_projectedTemplates", predicate: DfTemplate, isSignal: true }], ngImport: i0, template: `
14465
15536
  @if (shouldRender()) {
14466
15537
  @switch (formModeDetection().mode) {
14467
15538
  @case ('paged') {
@@ -14469,7 +15540,13 @@ class DynamicForm {
14469
15540
  }
14470
15541
  @case ('non-paged') {
14471
15542
  @for (field of resolvedFields(); track field.key) {
14472
- <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
15543
+ <!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
15544
+ the template @if removes the host from the DOM (Angular's prescribed pattern), and
15545
+ the directive's gate stops a same-CD-pass mount before [formField] can warn.
15546
+ Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
15547
+ @if (!field.hidden()) {
15548
+ <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
15549
+ }
14473
15550
  }
14474
15551
  }
14475
15552
  @default {
@@ -14477,9 +15554,9 @@ class DynamicForm {
14477
15554
  }
14478
15555
  }
14479
15556
  }
14480
- `, isInline: true, styles: [":host,.df-form{--df-grid-columns: 12;--df-grid-gap: .5rem;--df-grid-row-gap: .5rem;--df-breakpoint-sm: 576px;--df-breakpoint-md: 768px;--df-breakpoint-lg: 992px;--df-breakpoint-xl: 1200px;--df-grid-gap-sm: .5rem;--df-grid-gap-md: .5rem;--df-grid-gap-lg: .5rem;--df-grid-gap-xl: .5rem;--df-grid-row-gap-sm: .5rem;--df-grid-row-gap-md: .5rem;--df-grid-row-gap-lg: .5rem;--df-grid-row-gap-xl: .5rem;--df-array-item-gap: var(--df-grid-row-gap);--df-group-gap: var(--df-grid-gap);--df-group-padding: var(--df-grid-gap)}.df-form{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap);width:100%}.df-form>*{grid-column:1/-1}.df-row{display:grid;grid-template-columns:repeat(var(--df-grid-columns, 12),1fr);gap:var(--df-grid-gap);align-items:start;width:100%}.df-row>*:not([class*=df-col-]){grid-column:1/-1}.df-col-1{grid-column:span 1}.df-col-2{grid-column:span 2}.df-col-3{grid-column:span 3}.df-col-4{grid-column:span 4}.df-col-5{grid-column:span 5}.df-col-6{grid-column:span 6}.df-col-7{grid-column:span 7}.df-col-8{grid-column:span 8}.df-col-9{grid-column:span 9}.df-col-10{grid-column:span 10}.df-col-11{grid-column:span 11}.df-col-12{grid-column:span 12}.df-col-auto{grid-column:span auto;width:auto}.df-col-full{grid-column:1/-1}.df-col-start-1{grid-column-start:1}.df-col-start-2{grid-column-start:2}.df-col-start-3{grid-column-start:3}.df-col-start-4{grid-column-start:4}.df-col-start-5{grid-column-start:5}.df-col-start-6{grid-column-start:6}.df-col-start-7{grid-column-start:7}.df-col-start-8{grid-column-start:8}.df-col-start-9{grid-column-start:9}.df-col-start-10{grid-column-start:10}.df-col-start-11{grid-column-start:11}.df-col-start-12{grid-column-start:12}.df-col-end-1{grid-column-end:1}.df-col-end-2{grid-column-end:2}.df-col-end-3{grid-column-end:3}.df-col-end-4{grid-column-end:4}.df-col-end-5{grid-column-end:5}.df-col-end-6{grid-column-end:6}.df-col-end-7{grid-column-end:7}.df-col-end-8{grid-column-end:8}.df-col-end-9{grid-column-end:9}.df-col-end-10{grid-column-end:10}.df-col-end-11{grid-column-end:11}.df-col-end-12{grid-column-end:12}.df-col-end-13{grid-column-end:13}@media(max-width:576px){.df-form{--df-grid-gap: var(--df-grid-gap-sm);--df-grid-row-gap: var(--df-grid-row-gap-sm)}.df-row{grid-template-columns:1fr}.df-row>*{grid-column:1/-1!important}.df-row.df-row-mobile-keep-cols{grid-template-columns:repeat(var(--df-grid-columns),1fr)}.df-row.df-row-mobile-keep-cols>*{grid-column:revert!important}}@media(min-width:577px)and (max-width:768px){.df-form{--df-grid-gap: var(--df-grid-gap-md);--df-grid-row-gap: var(--df-grid-row-gap-md)}.df-row{--df-grid-columns: 6}.df-col-sm-1{grid-column:span 1}.df-col-sm-2{grid-column:span 2}.df-col-sm-3{grid-column:span 3}.df-col-sm-4{grid-column:span 4}.df-col-sm-5{grid-column:span 5}.df-col-sm-6{grid-column:span 6}.df-col-sm-full{grid-column:1/-1}}@media(min-width:769px)and (max-width:992px){.df-form{--df-grid-gap: var(--df-grid-gap-lg);--df-grid-row-gap: var(--df-grid-row-gap-lg)}.df-col-md-1{grid-column:span 1}.df-col-md-2{grid-column:span 2}.df-col-md-3{grid-column:span 3}.df-col-md-4{grid-column:span 4}.df-col-md-5{grid-column:span 5}.df-col-md-6{grid-column:span 6}.df-col-md-7{grid-column:span 7}.df-col-md-8{grid-column:span 8}.df-col-md-9{grid-column:span 9}.df-col-md-10{grid-column:span 10}.df-col-md-11{grid-column:span 11}.df-col-md-12{grid-column:span 12}.df-col-md-full{grid-column:1/-1}}@media(min-width:993px){.df-form{--df-grid-gap: var(--df-grid-gap-xl);--df-grid-row-gap: var(--df-grid-row-gap-xl)}.df-col-lg-1{grid-column:span 1}.df-col-lg-2{grid-column:span 2}.df-col-lg-3{grid-column:span 3}.df-col-lg-4{grid-column:span 4}.df-col-lg-5{grid-column:span 5}.df-col-lg-6{grid-column:span 6}.df-col-lg-7{grid-column:span 7}.df-col-lg-8{grid-column:span 8}.df-col-lg-9{grid-column:span 9}.df-col-lg-10{grid-column:span 10}.df-col-lg-11{grid-column:span 11}.df-col-lg-12{grid-column:span 12}.df-col-lg-full{grid-column:1/-1}}.df-gap-none{--df-grid-gap: 0}.df-gap-xs{--df-grid-gap: .25rem}.df-gap-sm{--df-grid-gap: .5rem}.df-gap-md{--df-grid-gap: 1rem}.df-gap-lg{--df-grid-gap: 1.5rem}.df-gap-xl{--df-grid-gap: 2rem}.df-row-gap-none{--df-grid-row-gap: 0}.df-row-gap-xs{--df-grid-row-gap: .25rem}.df-row-gap-sm{--df-grid-row-gap: .5rem}.df-row-gap-md{--df-grid-row-gap: 1rem}.df-row-gap-lg{--df-grid-row-gap: 1.5rem}.df-row-gap-xl{--df-grid-row-gap: 2rem}.df-field{display:block;width:100%;min-width:0;overflow:hidden;margin:0}.df-group,.df-page{display:block;width:100%}.df-form.disabled,.df-row.disabled,.df-field.disabled{opacity:.6;pointer-events:none}.df-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-sr-only-focusable:focus,.df-sr-only-focusable:active{position:static;width:auto;height:auto;padding:inherit;margin:inherit;overflow:visible;clip:auto;white-space:normal}.df-live-region{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-form :focus-visible{outline:2px solid var(--df-focus-color, #005fcc);outline-offset:2px}.df-group:focus-within{outline:1px dashed var(--df-focus-color, #005fcc);outline-offset:4px}:host{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap);width:100%}:host>*{grid-column:1/-1}:host.df-form-spacing-custom{--df-grid-row-gap: var(--form-custom-spacing, 1.5rem)}:host.df-form-gap-none{--df-grid-row-gap: 0}:host.df-form-gap-xs{--df-grid-row-gap: .25rem}:host.df-form-gap-sm{--df-grid-row-gap: .5rem}:host.df-form-gap-md{--df-grid-row-gap: 1rem}:host.df-form-gap-lg{--df-grid-row-gap: 1.5rem}:host.df-form-gap-xl{--df-grid-row-gap: 2rem}:host.df-form-paged{--df-grid-row-gap: 0}\n"], dependencies: [{ kind: "directive", type: DfFieldOutlet, selector: "[dfFieldOutlet]", inputs: ["dfFieldOutlet", "dfFieldOutletEnvironmentInjector"] }, { kind: "component", type: PageOrchestratorComponent, selector: "div[page-orchestrator]", inputs: ["pageFields", "form", "fieldSignalContext"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
15557
+ `, isInline: true, styles: [".df-form{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}.df-form>*{grid-column:1/-1}.df-row{display:grid;grid-template-columns:repeat(var(--df-grid-columns, 12),1fr);gap:var(--df-grid-gap, .5rem);align-items:start;width:100%}.df-row>*:not([class*=df-col-]){grid-column:1/-1}.df-col-1{grid-column:span 1}.df-col-2{grid-column:span 2}.df-col-3{grid-column:span 3}.df-col-4{grid-column:span 4}.df-col-5{grid-column:span 5}.df-col-6{grid-column:span 6}.df-col-7{grid-column:span 7}.df-col-8{grid-column:span 8}.df-col-9{grid-column:span 9}.df-col-10{grid-column:span 10}.df-col-11{grid-column:span 11}.df-col-12{grid-column:span 12}.df-col-auto{grid-column:span auto;width:auto}.df-col-full{grid-column:1/-1}.df-col-start-1{grid-column-start:1}.df-col-start-2{grid-column-start:2}.df-col-start-3{grid-column-start:3}.df-col-start-4{grid-column-start:4}.df-col-start-5{grid-column-start:5}.df-col-start-6{grid-column-start:6}.df-col-start-7{grid-column-start:7}.df-col-start-8{grid-column-start:8}.df-col-start-9{grid-column-start:9}.df-col-start-10{grid-column-start:10}.df-col-start-11{grid-column-start:11}.df-col-start-12{grid-column-start:12}.df-col-end-1{grid-column-end:1}.df-col-end-2{grid-column-end:2}.df-col-end-3{grid-column-end:3}.df-col-end-4{grid-column-end:4}.df-col-end-5{grid-column-end:5}.df-col-end-6{grid-column-end:6}.df-col-end-7{grid-column-end:7}.df-col-end-8{grid-column-end:8}.df-col-end-9{grid-column-end:9}.df-col-end-10{grid-column-end:10}.df-col-end-11{grid-column-end:11}.df-col-end-12{grid-column-end:12}.df-col-end-13{grid-column-end:13}@media(max-width:576px){.df-form{--df-grid-gap: var(--df-grid-gap-sm, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-sm, .5rem)}.df-row{grid-template-columns:1fr}.df-row>*{grid-column:1/-1!important}.df-row.df-row-mobile-keep-cols{grid-template-columns:repeat(var(--df-grid-columns, 12),1fr)}.df-row.df-row-mobile-keep-cols>*{grid-column:revert!important}}@media(min-width:577px)and (max-width:768px){.df-form{--df-grid-gap: var(--df-grid-gap-md, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-md, .5rem)}.df-row{--df-grid-columns: 6}.df-col-sm-1{grid-column:span 1}.df-col-sm-2{grid-column:span 2}.df-col-sm-3{grid-column:span 3}.df-col-sm-4{grid-column:span 4}.df-col-sm-5{grid-column:span 5}.df-col-sm-6{grid-column:span 6}.df-col-sm-full{grid-column:1/-1}}@media(min-width:769px)and (max-width:992px){.df-form{--df-grid-gap: var(--df-grid-gap-lg, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-lg, .5rem)}.df-col-md-1{grid-column:span 1}.df-col-md-2{grid-column:span 2}.df-col-md-3{grid-column:span 3}.df-col-md-4{grid-column:span 4}.df-col-md-5{grid-column:span 5}.df-col-md-6{grid-column:span 6}.df-col-md-7{grid-column:span 7}.df-col-md-8{grid-column:span 8}.df-col-md-9{grid-column:span 9}.df-col-md-10{grid-column:span 10}.df-col-md-11{grid-column:span 11}.df-col-md-12{grid-column:span 12}.df-col-md-full{grid-column:1/-1}}@media(min-width:993px){.df-form{--df-grid-gap: var(--df-grid-gap-xl, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-xl, .5rem)}.df-col-lg-1{grid-column:span 1}.df-col-lg-2{grid-column:span 2}.df-col-lg-3{grid-column:span 3}.df-col-lg-4{grid-column:span 4}.df-col-lg-5{grid-column:span 5}.df-col-lg-6{grid-column:span 6}.df-col-lg-7{grid-column:span 7}.df-col-lg-8{grid-column:span 8}.df-col-lg-9{grid-column:span 9}.df-col-lg-10{grid-column:span 10}.df-col-lg-11{grid-column:span 11}.df-col-lg-12{grid-column:span 12}.df-col-lg-full{grid-column:1/-1}}.df-gap-none{--df-grid-gap: 0}.df-gap-xs{--df-grid-gap: .25rem}.df-gap-sm{--df-grid-gap: .5rem}.df-gap-md{--df-grid-gap: 1rem}.df-gap-lg{--df-grid-gap: 1.5rem}.df-gap-xl{--df-grid-gap: 2rem}.df-row-gap-none{--df-grid-row-gap: 0}.df-row-gap-xs{--df-grid-row-gap: .25rem}.df-row-gap-sm{--df-grid-row-gap: .5rem}.df-row-gap-md{--df-grid-row-gap: 1rem}.df-row-gap-lg{--df-grid-row-gap: 1.5rem}.df-row-gap-xl{--df-grid-row-gap: 2rem}.df-field{display:block;width:100%;min-width:0;overflow:hidden;margin:0}.df-group,.df-page{display:block;width:100%}.df-form.disabled,.df-row.disabled,.df-field.disabled{opacity:.6;pointer-events:none}.df-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-sr-only-focusable:focus,.df-sr-only-focusable:active{position:static;width:auto;height:auto;padding:inherit;margin:inherit;overflow:visible;clip:auto;white-space:normal}.df-live-region{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-form :focus-visible{outline:2px solid var(--df-focus-color, #005fcc);outline-offset:2px}.df-group:focus-within{outline:1px dashed var(--df-focus-color, #005fcc);outline-offset:4px}:host{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}:host>*{grid-column:1/-1}:host.df-form-spacing-custom{--df-grid-row-gap: var(--df-form-custom-spacing, 1.5rem)}:host.df-form-gap-none{--df-grid-row-gap: 0}:host.df-form-gap-xs{--df-grid-row-gap: .25rem}:host.df-form-gap-sm{--df-grid-row-gap: .5rem}:host.df-form-gap-md{--df-grid-row-gap: 1rem}:host.df-form-gap-lg{--df-grid-row-gap: 1.5rem}:host.df-form-gap-xl{--df-grid-row-gap: 2rem}:host.df-form-paged{gap:0}\n"], dependencies: [{ kind: "directive", type: DfFieldOutlet, selector: "[dfFieldOutlet]", inputs: ["dfFieldOutlet", "dfFieldOutletEnvironmentInjector"] }, { kind: "component", type: PageOrchestratorComponent, selector: "div[page-orchestrator]", inputs: ["pageFields", "form", "fieldSignalContext"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14481
15558
  }
14482
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicForm, decorators: [{
15559
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicForm, decorators: [{
14483
15560
  type: Component,
14484
15561
  args: [{ selector: 'form[dynamic-form]', imports: [DfFieldOutlet, PageOrchestratorComponent], template: `
14485
15562
  @if (shouldRender()) {
@@ -14489,7 +15566,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14489
15566
  }
14490
15567
  @case ('non-paged') {
14491
15568
  @for (field of resolvedFields(); track field.key) {
14492
- <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
15569
+ <!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
15570
+ the template @if removes the host from the DOM (Angular's prescribed pattern), and
15571
+ the directive's gate stops a same-CD-pass mount before [formField] can warn.
15572
+ Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
15573
+ @if (!field.hidden()) {
15574
+ <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
15575
+ }
14493
15576
  }
14494
15577
  }
14495
15578
  @default {
@@ -14497,7 +15580,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14497
15580
  }
14498
15581
  }
14499
15582
  }
14500
- `, providers: [provideDynamicFormDI()], host: {
15583
+ `, providers: [
15584
+ provideDynamicFormDI(),
15585
+ DfFieldTemplateRegistry,
15586
+ { provide: DF_FIELD_TEMPLATES, useFactory: () => inject(DfFieldTemplateRegistry).map },
15587
+ ], host: {
14501
15588
  class: 'df-dynamic-form df-form',
14502
15589
  novalidate: '', // Disable browser validation - Angular Signal Forms handles validation
14503
15590
  '[class.disabled]': 'disabled()',
@@ -14505,8 +15592,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14505
15592
  '[class.df-form-non-paged]': 'formModeDetection().mode === "non-paged"',
14506
15593
  '[attr.data-form-mode]': 'formModeDetection().mode',
14507
15594
  '(submit)': 'onNativeSubmit($event)',
14508
- }, changeDetection: ChangeDetectionStrategy.OnPush, styles: [":host,.df-form{--df-grid-columns: 12;--df-grid-gap: .5rem;--df-grid-row-gap: .5rem;--df-breakpoint-sm: 576px;--df-breakpoint-md: 768px;--df-breakpoint-lg: 992px;--df-breakpoint-xl: 1200px;--df-grid-gap-sm: .5rem;--df-grid-gap-md: .5rem;--df-grid-gap-lg: .5rem;--df-grid-gap-xl: .5rem;--df-grid-row-gap-sm: .5rem;--df-grid-row-gap-md: .5rem;--df-grid-row-gap-lg: .5rem;--df-grid-row-gap-xl: .5rem;--df-array-item-gap: var(--df-grid-row-gap);--df-group-gap: var(--df-grid-gap);--df-group-padding: var(--df-grid-gap)}.df-form{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap);width:100%}.df-form>*{grid-column:1/-1}.df-row{display:grid;grid-template-columns:repeat(var(--df-grid-columns, 12),1fr);gap:var(--df-grid-gap);align-items:start;width:100%}.df-row>*:not([class*=df-col-]){grid-column:1/-1}.df-col-1{grid-column:span 1}.df-col-2{grid-column:span 2}.df-col-3{grid-column:span 3}.df-col-4{grid-column:span 4}.df-col-5{grid-column:span 5}.df-col-6{grid-column:span 6}.df-col-7{grid-column:span 7}.df-col-8{grid-column:span 8}.df-col-9{grid-column:span 9}.df-col-10{grid-column:span 10}.df-col-11{grid-column:span 11}.df-col-12{grid-column:span 12}.df-col-auto{grid-column:span auto;width:auto}.df-col-full{grid-column:1/-1}.df-col-start-1{grid-column-start:1}.df-col-start-2{grid-column-start:2}.df-col-start-3{grid-column-start:3}.df-col-start-4{grid-column-start:4}.df-col-start-5{grid-column-start:5}.df-col-start-6{grid-column-start:6}.df-col-start-7{grid-column-start:7}.df-col-start-8{grid-column-start:8}.df-col-start-9{grid-column-start:9}.df-col-start-10{grid-column-start:10}.df-col-start-11{grid-column-start:11}.df-col-start-12{grid-column-start:12}.df-col-end-1{grid-column-end:1}.df-col-end-2{grid-column-end:2}.df-col-end-3{grid-column-end:3}.df-col-end-4{grid-column-end:4}.df-col-end-5{grid-column-end:5}.df-col-end-6{grid-column-end:6}.df-col-end-7{grid-column-end:7}.df-col-end-8{grid-column-end:8}.df-col-end-9{grid-column-end:9}.df-col-end-10{grid-column-end:10}.df-col-end-11{grid-column-end:11}.df-col-end-12{grid-column-end:12}.df-col-end-13{grid-column-end:13}@media(max-width:576px){.df-form{--df-grid-gap: var(--df-grid-gap-sm);--df-grid-row-gap: var(--df-grid-row-gap-sm)}.df-row{grid-template-columns:1fr}.df-row>*{grid-column:1/-1!important}.df-row.df-row-mobile-keep-cols{grid-template-columns:repeat(var(--df-grid-columns),1fr)}.df-row.df-row-mobile-keep-cols>*{grid-column:revert!important}}@media(min-width:577px)and (max-width:768px){.df-form{--df-grid-gap: var(--df-grid-gap-md);--df-grid-row-gap: var(--df-grid-row-gap-md)}.df-row{--df-grid-columns: 6}.df-col-sm-1{grid-column:span 1}.df-col-sm-2{grid-column:span 2}.df-col-sm-3{grid-column:span 3}.df-col-sm-4{grid-column:span 4}.df-col-sm-5{grid-column:span 5}.df-col-sm-6{grid-column:span 6}.df-col-sm-full{grid-column:1/-1}}@media(min-width:769px)and (max-width:992px){.df-form{--df-grid-gap: var(--df-grid-gap-lg);--df-grid-row-gap: var(--df-grid-row-gap-lg)}.df-col-md-1{grid-column:span 1}.df-col-md-2{grid-column:span 2}.df-col-md-3{grid-column:span 3}.df-col-md-4{grid-column:span 4}.df-col-md-5{grid-column:span 5}.df-col-md-6{grid-column:span 6}.df-col-md-7{grid-column:span 7}.df-col-md-8{grid-column:span 8}.df-col-md-9{grid-column:span 9}.df-col-md-10{grid-column:span 10}.df-col-md-11{grid-column:span 11}.df-col-md-12{grid-column:span 12}.df-col-md-full{grid-column:1/-1}}@media(min-width:993px){.df-form{--df-grid-gap: var(--df-grid-gap-xl);--df-grid-row-gap: var(--df-grid-row-gap-xl)}.df-col-lg-1{grid-column:span 1}.df-col-lg-2{grid-column:span 2}.df-col-lg-3{grid-column:span 3}.df-col-lg-4{grid-column:span 4}.df-col-lg-5{grid-column:span 5}.df-col-lg-6{grid-column:span 6}.df-col-lg-7{grid-column:span 7}.df-col-lg-8{grid-column:span 8}.df-col-lg-9{grid-column:span 9}.df-col-lg-10{grid-column:span 10}.df-col-lg-11{grid-column:span 11}.df-col-lg-12{grid-column:span 12}.df-col-lg-full{grid-column:1/-1}}.df-gap-none{--df-grid-gap: 0}.df-gap-xs{--df-grid-gap: .25rem}.df-gap-sm{--df-grid-gap: .5rem}.df-gap-md{--df-grid-gap: 1rem}.df-gap-lg{--df-grid-gap: 1.5rem}.df-gap-xl{--df-grid-gap: 2rem}.df-row-gap-none{--df-grid-row-gap: 0}.df-row-gap-xs{--df-grid-row-gap: .25rem}.df-row-gap-sm{--df-grid-row-gap: .5rem}.df-row-gap-md{--df-grid-row-gap: 1rem}.df-row-gap-lg{--df-grid-row-gap: 1.5rem}.df-row-gap-xl{--df-grid-row-gap: 2rem}.df-field{display:block;width:100%;min-width:0;overflow:hidden;margin:0}.df-group,.df-page{display:block;width:100%}.df-form.disabled,.df-row.disabled,.df-field.disabled{opacity:.6;pointer-events:none}.df-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-sr-only-focusable:focus,.df-sr-only-focusable:active{position:static;width:auto;height:auto;padding:inherit;margin:inherit;overflow:visible;clip:auto;white-space:normal}.df-live-region{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-form :focus-visible{outline:2px solid var(--df-focus-color, #005fcc);outline-offset:2px}.df-group:focus-within{outline:1px dashed var(--df-focus-color, #005fcc);outline-offset:4px}:host{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap);width:100%}:host>*{grid-column:1/-1}:host.df-form-spacing-custom{--df-grid-row-gap: var(--form-custom-spacing, 1.5rem)}:host.df-form-gap-none{--df-grid-row-gap: 0}:host.df-form-gap-xs{--df-grid-row-gap: .25rem}:host.df-form-gap-sm{--df-grid-row-gap: .5rem}:host.df-form-gap-md{--df-grid-row-gap: 1rem}:host.df-form-gap-lg{--df-grid-row-gap: 1.5rem}:host.df-form-gap-xl{--df-grid-row-gap: 2rem}:host.df-form-paged{--df-grid-row-gap: 0}\n"] }]
14509
- }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamic-form", required: true }] }], formOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "formOptions", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], validityChange: [{ type: i0.Output, args: ["validityChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], submitted: [{ type: i0.Output, args: ["submitted"] }], reset: [{ type: i0.Output, args: ["reset"] }], cleared: [{ type: i0.Output, args: ["cleared"] }], events: [{ type: i0.Output, args: ["events"] }], initialized: [{ type: i0.Output, args: ["initialized"] }], onPageChange: [{ type: i0.Output, args: ["onPageChange"] }], onPageNavigationStateChange: [{ type: i0.Output, args: ["onPageNavigationStateChange"] }] } });
15595
+ }, changeDetection: ChangeDetectionStrategy.OnPush, styles: [".df-form{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}.df-form>*{grid-column:1/-1}.df-row{display:grid;grid-template-columns:repeat(var(--df-grid-columns, 12),1fr);gap:var(--df-grid-gap, .5rem);align-items:start;width:100%}.df-row>*:not([class*=df-col-]){grid-column:1/-1}.df-col-1{grid-column:span 1}.df-col-2{grid-column:span 2}.df-col-3{grid-column:span 3}.df-col-4{grid-column:span 4}.df-col-5{grid-column:span 5}.df-col-6{grid-column:span 6}.df-col-7{grid-column:span 7}.df-col-8{grid-column:span 8}.df-col-9{grid-column:span 9}.df-col-10{grid-column:span 10}.df-col-11{grid-column:span 11}.df-col-12{grid-column:span 12}.df-col-auto{grid-column:span auto;width:auto}.df-col-full{grid-column:1/-1}.df-col-start-1{grid-column-start:1}.df-col-start-2{grid-column-start:2}.df-col-start-3{grid-column-start:3}.df-col-start-4{grid-column-start:4}.df-col-start-5{grid-column-start:5}.df-col-start-6{grid-column-start:6}.df-col-start-7{grid-column-start:7}.df-col-start-8{grid-column-start:8}.df-col-start-9{grid-column-start:9}.df-col-start-10{grid-column-start:10}.df-col-start-11{grid-column-start:11}.df-col-start-12{grid-column-start:12}.df-col-end-1{grid-column-end:1}.df-col-end-2{grid-column-end:2}.df-col-end-3{grid-column-end:3}.df-col-end-4{grid-column-end:4}.df-col-end-5{grid-column-end:5}.df-col-end-6{grid-column-end:6}.df-col-end-7{grid-column-end:7}.df-col-end-8{grid-column-end:8}.df-col-end-9{grid-column-end:9}.df-col-end-10{grid-column-end:10}.df-col-end-11{grid-column-end:11}.df-col-end-12{grid-column-end:12}.df-col-end-13{grid-column-end:13}@media(max-width:576px){.df-form{--df-grid-gap: var(--df-grid-gap-sm, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-sm, .5rem)}.df-row{grid-template-columns:1fr}.df-row>*{grid-column:1/-1!important}.df-row.df-row-mobile-keep-cols{grid-template-columns:repeat(var(--df-grid-columns, 12),1fr)}.df-row.df-row-mobile-keep-cols>*{grid-column:revert!important}}@media(min-width:577px)and (max-width:768px){.df-form{--df-grid-gap: var(--df-grid-gap-md, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-md, .5rem)}.df-row{--df-grid-columns: 6}.df-col-sm-1{grid-column:span 1}.df-col-sm-2{grid-column:span 2}.df-col-sm-3{grid-column:span 3}.df-col-sm-4{grid-column:span 4}.df-col-sm-5{grid-column:span 5}.df-col-sm-6{grid-column:span 6}.df-col-sm-full{grid-column:1/-1}}@media(min-width:769px)and (max-width:992px){.df-form{--df-grid-gap: var(--df-grid-gap-lg, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-lg, .5rem)}.df-col-md-1{grid-column:span 1}.df-col-md-2{grid-column:span 2}.df-col-md-3{grid-column:span 3}.df-col-md-4{grid-column:span 4}.df-col-md-5{grid-column:span 5}.df-col-md-6{grid-column:span 6}.df-col-md-7{grid-column:span 7}.df-col-md-8{grid-column:span 8}.df-col-md-9{grid-column:span 9}.df-col-md-10{grid-column:span 10}.df-col-md-11{grid-column:span 11}.df-col-md-12{grid-column:span 12}.df-col-md-full{grid-column:1/-1}}@media(min-width:993px){.df-form{--df-grid-gap: var(--df-grid-gap-xl, .5rem);--df-grid-row-gap: var(--df-grid-row-gap-xl, .5rem)}.df-col-lg-1{grid-column:span 1}.df-col-lg-2{grid-column:span 2}.df-col-lg-3{grid-column:span 3}.df-col-lg-4{grid-column:span 4}.df-col-lg-5{grid-column:span 5}.df-col-lg-6{grid-column:span 6}.df-col-lg-7{grid-column:span 7}.df-col-lg-8{grid-column:span 8}.df-col-lg-9{grid-column:span 9}.df-col-lg-10{grid-column:span 10}.df-col-lg-11{grid-column:span 11}.df-col-lg-12{grid-column:span 12}.df-col-lg-full{grid-column:1/-1}}.df-gap-none{--df-grid-gap: 0}.df-gap-xs{--df-grid-gap: .25rem}.df-gap-sm{--df-grid-gap: .5rem}.df-gap-md{--df-grid-gap: 1rem}.df-gap-lg{--df-grid-gap: 1.5rem}.df-gap-xl{--df-grid-gap: 2rem}.df-row-gap-none{--df-grid-row-gap: 0}.df-row-gap-xs{--df-grid-row-gap: .25rem}.df-row-gap-sm{--df-grid-row-gap: .5rem}.df-row-gap-md{--df-grid-row-gap: 1rem}.df-row-gap-lg{--df-grid-row-gap: 1.5rem}.df-row-gap-xl{--df-grid-row-gap: 2rem}.df-field{display:block;width:100%;min-width:0;overflow:hidden;margin:0}.df-group,.df-page{display:block;width:100%}.df-form.disabled,.df-row.disabled,.df-field.disabled{opacity:.6;pointer-events:none}.df-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-sr-only-focusable:focus,.df-sr-only-focusable:active{position:static;width:auto;height:auto;padding:inherit;margin:inherit;overflow:visible;clip:auto;white-space:normal}.df-live-region{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.df-form :focus-visible{outline:2px solid var(--df-focus-color, #005fcc);outline-offset:2px}.df-group:focus-within{outline:1px dashed var(--df-focus-color, #005fcc);outline-offset:4px}:host{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}:host>*{grid-column:1/-1}:host.df-form-spacing-custom{--df-grid-row-gap: var(--df-form-custom-spacing, 1.5rem)}:host.df-form-gap-none{--df-grid-row-gap: 0}:host.df-form-gap-xs{--df-grid-row-gap: .25rem}:host.df-form-gap-sm{--df-grid-row-gap: .5rem}:host.df-form-gap-md{--df-grid-row-gap: 1rem}:host.df-form-gap-lg{--df-grid-row-gap: 1.5rem}:host.df-form-gap-xl{--df-grid-row-gap: 2rem}:host.df-form-paged{gap:0}\n"] }]
15596
+ }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "dynamic-form", required: true }] }], formOptions: [{ type: i0.Input, args: [{ isSignal: true, alias: "formOptions", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], source: [{ type: i0.Input, args: [{ isSignal: true, alias: "source", required: false }] }], _projectedTemplates: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => DfTemplate), { isSignal: true }] }], validityChange: [{ type: i0.Output, args: ["validityChange"] }], dirtyChange: [{ type: i0.Output, args: ["dirtyChange"] }], submitted: [{ type: i0.Output, args: ["submitted"] }], reset: [{ type: i0.Output, args: ["reset"] }], cleared: [{ type: i0.Output, args: ["cleared"] }], events: [{ type: i0.Output, args: ["events"] }], initialized: [{ type: i0.Output, args: ["initialized"] }], onPageChange: [{ type: i0.Output, args: ["onPageChange"] }], onPageNavigationStateChange: [{ type: i0.Output, args: ["onPageNavigationStateChange"] }] } });
14510
15597
  /**
14511
15598
  * Recursively counts container fields (page, row, group, array, wrapper) in a field tree.
14512
15599
  * Descends into container children including array item templates to ensure
@@ -14537,11 +15624,152 @@ function countContainersRecursive(fields) {
14537
15624
  }
14538
15625
 
14539
15626
  /**
14540
- * No-op logger for when no logger is provided.
14541
- * Used as fallback in button logic evaluation to avoid breaking downstream packages.
14542
- */
14543
- const noOpLogger = {
14544
- // eslint-disable-next-line @typescript-eslint/no-empty-function
15627
+ * Cache for resolved addon-kind components.
15628
+ *
15629
+ * Provided per-form by `provideDynamicFormDI()` so two `<df-dynamic-form>`
15630
+ * instances with conflicting `loadComponent` factories for the same kind
15631
+ * never share a stale entry. Has no `providedIn: 'root'` factory — the
15632
+ * registry consumer is always inside a form scope, and a root fallback
15633
+ * would only hide misconfiguration (an SSR cross-request hit if a future
15634
+ * code path forgot to provide it locally).
15635
+ *
15636
+ * Mirrors `COMPONENT_CACHE` (field types) and `WRAPPER_COMPONENT_CACHE`
15637
+ * (wrappers).
15638
+ *
15639
+ * @internal
15640
+ */
15641
+ const ADDON_KIND_COMPONENT_CACHE = new InjectionToken('ADDON_KIND_COMPONENT_CACHE');
15642
+ /**
15643
+ * Injection function for accessing the addon kind registry.
15644
+ *
15645
+ * Usage parallels {@link injectFieldRegistry}: lookup, lazy load, cache.
15646
+ * Must be called within an injection context.
15647
+ *
15648
+ * @example
15649
+ * ```typescript
15650
+ * const addonKinds = injectAddonKindRegistry();
15651
+ * const Component = await addonKinds.loadKindComponent('prime-icon');
15652
+ * ```
15653
+ */
15654
+ function injectAddonKindRegistry() {
15655
+ const registry = inject(ADDON_KIND_REGISTRY);
15656
+ const componentCache = inject(ADDON_KIND_COMPONENT_CACHE);
15657
+ return {
15658
+ /** Look up a kind definition by its discriminant. */
15659
+ getKind(kind) {
15660
+ return registry.get(kind);
15661
+ },
15662
+ /** Whether a kind is currently registered. */
15663
+ hasKind(kind) {
15664
+ return registry.has(kind);
15665
+ },
15666
+ /** All registered kind names — useful for actionable validator warnings. */
15667
+ getKindNames() {
15668
+ return Array.from(registry.keys());
15669
+ },
15670
+ /**
15671
+ * Resolve a kind to its renderer component. Caches on first resolution.
15672
+ *
15673
+ * @throws DynamicFormError when the kind is not registered.
15674
+ */
15675
+ async loadKindComponent(kind) {
15676
+ const definition = registry.get(kind);
15677
+ if (!definition) {
15678
+ const known = Array.from(registry.keys()).join(', ') || '(none)';
15679
+ throw new DynamicFormError(`Addon kind '${kind}' is not registered. Currently registered kinds: ${known}.`);
15680
+ }
15681
+ const cached = componentCache.get(kind);
15682
+ if (cached) {
15683
+ return cached;
15684
+ }
15685
+ try {
15686
+ const component = resolveDefaultExport(await definition.loadComponent());
15687
+ if (component) {
15688
+ componentCache.set(kind, component);
15689
+ return component;
15690
+ }
15691
+ throw new DynamicFormError(`Addon kind '${kind}' loadComponent resolved to null.`);
15692
+ }
15693
+ catch (error) {
15694
+ if (error instanceof DynamicFormError)
15695
+ throw error;
15696
+ throw new DynamicFormError(`Failed to load component for addon kind '${kind}': ${error instanceof Error ? error.message : String(error)}`);
15697
+ }
15698
+ },
15699
+ /**
15700
+ * Synchronous lookup of an already-loaded kind component, or undefined.
15701
+ * Returns `undefined` when the kind isn't registered in this scope even
15702
+ * if a prior scope cached a component under the same name — prevents
15703
+ * cross-form cache hits when registrations diverge.
15704
+ */
15705
+ getLoadedKindComponent(kind) {
15706
+ if (!registry.has(kind))
15707
+ return undefined;
15708
+ return componentCache.get(kind);
15709
+ },
15710
+ /** Direct read-only access to the registry map. */
15711
+ get raw() {
15712
+ return registry;
15713
+ },
15714
+ };
15715
+ }
15716
+
15717
+ /**
15718
+ * Built-in addon kinds shipped by core.
15719
+ *
15720
+ * Universally adapter-independent: `text`, `template`, `component`. Adapters
15721
+ * register their own kinds (e.g., `prime-icon`, `prime-button`) via
15722
+ * `withCustomAddon(...)` features.
15723
+ */
15724
+ const BUILT_IN_ADDON_KINDS = [
15725
+ {
15726
+ kind: 'text',
15727
+ loadComponent: () => Promise.resolve().then(function () { return textAddon_component; }).then((m) => m.TextAddonComponent),
15728
+ validate: (addon, fieldKey) => {
15729
+ const text = addon.text;
15730
+ // Validates structural presence: rejects missing / null / non-string-non-callable,
15731
+ // and rejects literal empty strings. DynamicText values that resolve to empty
15732
+ // at runtime (signal/observable yielding '') are NOT caught here — they're
15733
+ // legal as far as the config is concerned, just render an empty span.
15734
+ if (text === undefined || text === null) {
15735
+ throw new DynamicFormError(`Addon kind 'text' requires a 'text' field (field: '${fieldKey}').`);
15736
+ }
15737
+ if (typeof text === 'string' && text.length === 0) {
15738
+ throw new DynamicFormError(`Addon kind 'text' requires a non-empty 'text' string (field: '${fieldKey}').`);
15739
+ }
15740
+ },
15741
+ },
15742
+ {
15743
+ kind: 'template',
15744
+ loadComponent: () => Promise.resolve().then(function () { return templateAddon_component; }).then((m) => m.TemplateAddonComponent),
15745
+ validate: (addon, fieldKey) => {
15746
+ const key = addon.templateKey;
15747
+ if (typeof key !== 'string' || key.length === 0) {
15748
+ throw new DynamicFormError(`Addon kind 'template' requires a non-empty 'templateKey' string (field: '${fieldKey}').`);
15749
+ }
15750
+ },
15751
+ },
15752
+ {
15753
+ kind: 'component',
15754
+ // The shape carries a function loader — not serialisable; dropped by the
15755
+ // validator when the form config originated from JSON.
15756
+ jsonSafe: false,
15757
+ loadComponent: () => Promise.resolve().then(function () { return componentAddon_component; }).then((m) => m.ComponentAddonComponent),
15758
+ validate: (addon, fieldKey) => {
15759
+ const loader = addon.component;
15760
+ if (typeof loader !== 'function') {
15761
+ throw new DynamicFormError(`Addon kind 'component' requires a 'component' loader function (field: '${fieldKey}').`);
15762
+ }
15763
+ },
15764
+ },
15765
+ ];
15766
+
15767
+ /**
15768
+ * No-op logger for when no logger is provided.
15769
+ * Used as fallback in button logic evaluation to avoid breaking downstream packages.
15770
+ */
15771
+ const noOpLogger = {
15772
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
14545
15773
  debug: () => { },
14546
15774
  // eslint-disable-next-line @typescript-eslint/no-empty-function
14547
15775
  info: () => { },
@@ -15072,8 +16300,14 @@ function containerFieldMapper(fieldDef) {
15072
16300
  * ```
15073
16301
  */
15074
16302
  /**
15075
- * Base field type definitions for fields that don't use the `field` input.
15076
- * Used by display-only fields like `text` that render immediately without
16303
+ * Base for container field types that consume the mapper-emitted `field` input
16304
+ * (row, group, array, page, container).
16305
+ */
16306
+ const CONTAINER_FIELD_TYPES_BASE = {
16307
+ renderReadyWhen: ['field'],
16308
+ };
16309
+ /**
16310
+ * Base for display-only fields (`text`) that render immediately without
15077
16311
  * waiting for form value integration.
15078
16312
  */
15079
16313
  const DISPLAY_FIELD_TYPES_BASE = {
@@ -15092,31 +16326,35 @@ const BUILT_IN_FIELDS = [
15092
16326
  // `row` is a virtual field type: it maps to ContainerFieldComponent, and
15093
16327
  // the rowFieldMapper injects a `{ type: 'row' }` wrapper so the container
15094
16328
  // renders the flex/grid layout via RowWrapperComponent.
15095
- loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-D4Fpr2ri.mjs'),
16329
+ loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-CrOIZEL_.mjs'),
15096
16330
  mapper: rowFieldMapper,
15097
16331
  valueHandling: 'flatten',
16332
+ ...CONTAINER_FIELD_TYPES_BASE,
15098
16333
  },
15099
16334
  {
15100
16335
  name: 'group',
15101
- loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-BxswrhOc.mjs'),
16336
+ loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-DqnhddRH.mjs'),
15102
16337
  mapper: groupFieldMapper,
15103
16338
  valueHandling: 'include',
16339
+ ...CONTAINER_FIELD_TYPES_BASE,
15104
16340
  },
15105
16341
  {
15106
16342
  name: 'array',
15107
- loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-CltYGHg9.mjs'),
16343
+ loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-Tw49K5kz.mjs'),
15108
16344
  mapper: arrayFieldMapper,
15109
16345
  valueHandling: 'include',
16346
+ ...CONTAINER_FIELD_TYPES_BASE,
15110
16347
  },
15111
16348
  {
15112
16349
  name: 'page',
15113
- loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-BHNVryIq.mjs'),
16350
+ loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-n4KTMOTm.mjs'),
15114
16351
  mapper: pageFieldMapper,
15115
16352
  valueHandling: 'flatten',
16353
+ ...CONTAINER_FIELD_TYPES_BASE,
15116
16354
  },
15117
16355
  {
15118
16356
  name: 'text',
15119
- loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-Cf1mEKVW.mjs'),
16357
+ loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-C14mlROS.mjs'),
15120
16358
  mapper: textFieldMapper,
15121
16359
  valueHandling: 'exclude',
15122
16360
  ...DISPLAY_FIELD_TYPES_BASE,
@@ -15128,22 +16366,32 @@ const BUILT_IN_FIELDS = [
15128
16366
  },
15129
16367
  {
15130
16368
  name: 'container',
15131
- loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-D4Fpr2ri.mjs'),
16369
+ loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-CrOIZEL_.mjs'),
15132
16370
  mapper: containerFieldMapper,
15133
16371
  valueHandling: 'flatten',
16372
+ ...CONTAINER_FIELD_TYPES_BASE,
15134
16373
  },
15135
16374
  ];
15136
16375
  const BUILT_IN_WRAPPERS = [
15137
16376
  {
15138
16377
  wrapperName: 'css',
15139
- loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-mdfySFjX.mjs'),
16378
+ loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-Q2g56KUM.mjs'),
15140
16379
  },
15141
16380
  {
15142
16381
  wrapperName: 'row',
15143
- loadComponent: () => import('./ng-forge-dynamic-forms-row-wrapper.component-BIvEUoia.mjs'),
16382
+ loadComponent: () => import('./ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs'),
15144
16383
  },
15145
16384
  ];
15146
16385
 
16386
+ /**
16387
+ * Multi-provider token used by `withCustomAddon(...)` features to contribute
16388
+ * addon kind definitions. `provideDynamicForm` aggregates all values into
16389
+ * the form-scoped `ADDON_KIND_REGISTRY` map.
16390
+ *
16391
+ * @internal
16392
+ */
16393
+ const ADDON_KIND_DEFINITIONS = new InjectionToken('ADDON_KIND_DEFINITIONS');
16394
+
15147
16395
  /**
15148
16396
  * Bundle wrapper registrations into a single object that can be passed to
15149
16397
  * `provideDynamicForm(...)`.
@@ -15260,8 +16508,6 @@ function provideDynamicForm(...items) {
15260
16508
  const defaultLoggerProvider = hasLoggerFeature ? [] : [{ provide: DynamicFormLogger, useValue: new ConsoleLogger() }];
15261
16509
  return makeEnvironmentProviders([
15262
16510
  ...defaultLoggerProvider,
15263
- // Always provide default Signal Forms classes (ng-touched, ng-invalid, etc.)
15264
- provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }),
15265
16511
  {
15266
16512
  provide: FIELD_REGISTRY,
15267
16513
  useFactory: () => {
@@ -15312,6 +16558,31 @@ function provideDynamicForm(...items) {
15312
16558
  return autoMap;
15313
16559
  },
15314
16560
  },
16561
+ // Form-scoped addon kind registry. Built from BUILT_IN_ADDON_KINDS plus
16562
+ // every kind contributed by `withCustomAddon(...)` features (via the
16563
+ // ADDON_KIND_DEFINITIONS multi-provider). Overrides the empty
16564
+ // `providedIn: 'root'` default so contributions from form-scoped
16565
+ // providers are visible.
16566
+ {
16567
+ provide: ADDON_KIND_REGISTRY,
16568
+ useFactory: () => {
16569
+ const logger = inject(DynamicFormLogger);
16570
+ const contributed = inject(ADDON_KIND_DEFINITIONS, { optional: true }) ?? [];
16571
+ const registry = new Map();
16572
+ for (const def of BUILT_IN_ADDON_KINDS) {
16573
+ registry.set(def.kind, def);
16574
+ }
16575
+ for (const def of contributed) {
16576
+ if (registry.has(def.kind)) {
16577
+ logger.warn(`Addon kind "${def.kind}" is already registered. Overwriting.`);
16578
+ }
16579
+ registry.set(def.kind, def);
16580
+ }
16581
+ return registry;
16582
+ },
16583
+ },
16584
+ // Per-form addon-component cache (no root fallback — isolated by form scope).
16585
+ { provide: ADDON_KIND_COMPONENT_CACHE, useFactory: () => new Map() },
15315
16586
  ...featureProviders,
15316
16587
  ]);
15317
16588
  }
@@ -15488,6 +16759,35 @@ function withValidationExecutionDefaults(config) {
15488
16759
  return createFeature('validation-execution', [{ provide: VALIDATION_EXECUTION_DEFAULTS, useValue: resolved }]);
15489
16760
  }
15490
16761
 
16762
+ /**
16763
+ * Opt into Angular Signal Forms' legacy `.ng-touched` / `.ng-invalid` /
16764
+ * `.ng-dirty` / `.ng-pristine` / `.ng-untouched` / `.ng-pending` /
16765
+ * `.ng-valid` CSS classes on form-bound elements.
16766
+ *
16767
+ * Wires Angular's `@angular/forms/signals/compat` `NG_STATUS_CLASSES` strategy
16768
+ * via `provideSignalFormsConfig`. Without this feature the modern Signal Forms
16769
+ * class strategy is active, which omits the legacy `ng-*` classes.
16770
+ *
16771
+ * @example Opt in once at the application root
16772
+ * ```typescript
16773
+ * bootstrapApplication(AppComponent, {
16774
+ * providers: [
16775
+ * provideDynamicForm(
16776
+ * ...withMaterialFields(),
16777
+ * withLegacyStatusClasses()
16778
+ * )
16779
+ * ]
16780
+ * });
16781
+ * ```
16782
+ *
16783
+ * @returns A DynamicFormFeature that enables the legacy `ng-*` class strategy
16784
+ *
16785
+ * @public
16786
+ */
16787
+ function withLegacyStatusClasses() {
16788
+ return createFeature('legacy-status-classes', [...provideSignalFormsConfig({ classes: NG_STATUS_CLASSES })]);
16789
+ }
16790
+
15491
16791
  /**
15492
16792
  * Zero-cost type carrier used in wrapper registrations.
15493
16793
  *
@@ -15515,6 +16815,713 @@ function wrapperProps() {
15515
16815
  return undefined;
15516
16816
  }
15517
16817
 
16818
+ /** Type guard narrowing {@link AddonActionContext} to its field-bound variant. */
16819
+ function isFieldBoundContext(ctx) {
16820
+ return ctx.form !== null;
16821
+ }
16822
+
16823
+ /**
16824
+ * Normalises a {@link DynamicValue} into a `Signal<T>`.
16825
+ *
16826
+ * Accepts a static value, a `Signal<T>`, or an `Observable<T>`. The result
16827
+ * is a `Signal<T>` that reactively reflects the current value, suitable
16828
+ * for direct use in Angular templates.
16829
+ *
16830
+ * Static values yield a `computed` that always returns the same constant —
16831
+ * preferable to wrapping in `signal()` because the computed never causes
16832
+ * change-detection invalidation.
16833
+ *
16834
+ * Pass an `injector` when calling outside an Angular injection context
16835
+ * (e.g., from inside a `computed`). `toSignal` needs a `DestroyRef` to
16836
+ * unsubscribe — without an injector it falls back to `inject(DestroyRef)`,
16837
+ * which throws when called outside injection context.
16838
+ *
16839
+ * @example
16840
+ * ```typescript
16841
+ * const isHidden = resolveDynamicValue(addon().hidden, false);
16842
+ * // template: \@if (isHidden()) { ... }
16843
+ * ```
16844
+ */
16845
+ function resolveDynamicValue(value, fallback, injector) {
16846
+ if (value === undefined) {
16847
+ return computed(() => fallback);
16848
+ }
16849
+ if (isSignal(value)) {
16850
+ return value;
16851
+ }
16852
+ if (isObservable(value)) {
16853
+ return toSignal(value, { initialValue: fallback, injector });
16854
+ }
16855
+ // Static value
16856
+ return computed(() => value);
16857
+ }
16858
+
16859
+ /**
16860
+ * Dispatcher component that renders an addon by looking up its `kind` in
16861
+ * `ADDON_KIND_REGISTRY` and instantiating the registered component.
16862
+ *
16863
+ * Used by every adapter's field component to render slot content:
16864
+ *
16865
+ * ```html
16866
+ * \@for (a of addonsAt('prefix'); track $index) {
16867
+ * <df-addon-slot [addon]="a" />
16868
+ * }
16869
+ * ```
16870
+ *
16871
+ * Behaviour:
16872
+ * - Resolves and caches the kind's component asynchronously on first render.
16873
+ * - Forwards the `slot` HTML attribute on the host so Ionic shadow-DOM
16874
+ * projection works (`<ion-input>` projects children with `slot="start"`
16875
+ * into its native start slot).
16876
+ * - Honours the addon's reactive `hidden` flag — when `true`, the host is
16877
+ * `display: none` so it occupies no layout but stays in DOM (cheaper than
16878
+ * tearing down the component).
16879
+ * - Forwards `className` to the host element.
16880
+ * - ARIA semantics are owned by individual kind components — decorative
16881
+ * kinds (icons, text) set `aria-hidden="true"` on themselves; interactive
16882
+ * kinds (buttons) handle their own `aria-label`.
16883
+ *
16884
+ * If the kind cannot be resolved (registry miss, load failure), the
16885
+ * dispatcher logs a warning and renders nothing — never throws. The
16886
+ * runtime addon validator should have caught unknown kinds at config init,
16887
+ * but this provides a defence-in-depth fallback for misconfigured registries.
16888
+ */
16889
+ class DfAddonSlot {
16890
+ registry = injectAddonKindRegistry();
16891
+ logger = inject(DynamicFormLogger);
16892
+ hostInjector = inject(Injector);
16893
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
16894
+ /**
16895
+ * Optional read-only view of the host field's mapped inputs — same shape
16896
+ * wrappers receive (`field?: ReadonlyFieldTree`, `key`, `props`, etc.).
16897
+ * Forwarded to kind components so they can read field state without
16898
+ * re-injecting `FIELD_SIGNAL_CONTEXT` and rebuilding context themselves.
16899
+ *
16900
+ * `undefined` when the addon is rendered outside a field (rare).
16901
+ */
16902
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
16903
+ /**
16904
+ * Pre-resolved `hidden` signal supplied by the host field. When provided,
16905
+ * the dispatcher reads this instead of re-resolving `addon.hidden` —
16906
+ * lets the host (e.g., `prime-input`) share a single subscription per
16907
+ * Observable-typed `hidden` between its own visibility filter and the
16908
+ * slot's `[style.display]` binding.
16909
+ */
16910
+ hidden = input(undefined, ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
16911
+ /** `slot` HTML attribute forwarded to host — needed for Ionic shadow projection. */
16912
+ slotAttr = computed(() => this.addon().slot, ...(ngDevMode ? [{ debugName: "slotAttr" }] : /* istanbul ignore next */ []));
16913
+ className = computed(() => this.addon().className ?? null, ...(ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []));
16914
+ /**
16915
+ * Locally-resolved `hidden` signal — always reflects `addon.hidden`
16916
+ * regardless of whether the host supplied a pre-resolved signal.
16917
+ * Populated via `explicitEffect` so `resolveDynamicValue` (which may call
16918
+ * `toSignal` for Observable-typed values) does NOT run inside a reactive
16919
+ * context (NG0602). The WeakMap caches per `hidden`-value identity, so
16920
+ * swapping `addon` inputs that share the same Observable reuses the
16921
+ * subscription instead of leaking a fresh one per swap.
16922
+ *
16923
+ * Resolved unconditionally (not gated on host `hidden()` presence) so a
16924
+ * transition from `host hidden = Signal` → `undefined` doesn't expose a
16925
+ * brief window where neither value is current — the upstream effect
16926
+ * re-runs eventually, but the computed read can happen in between.
16927
+ */
16928
+ _hiddenByValue = new WeakMap();
16929
+ _resolvedHidden = signal(undefined, ...(ngDevMode ? [{ debugName: "_resolvedHidden" }] : /* istanbul ignore next */ []));
16930
+ /**
16931
+ * `hidden` resolved from `DynamicValue<boolean>` to a flat `Signal<boolean>`.
16932
+ * Prefers the host-supplied signal when present (avoids duplicate `toSignal`
16933
+ * subscriptions for Observable-typed `hidden`); falls back to the locally-
16934
+ * resolved signal, which is always populated by the addon-tracking effect.
16935
+ */
16936
+ isHidden = computed(() => {
16937
+ const pre = this.hidden();
16938
+ if (pre)
16939
+ return pre();
16940
+ const local = this._resolvedHidden();
16941
+ return local ? local() : false;
16942
+ }, ...(ngDevMode ? [{ debugName: "isHidden" }] : /* istanbul ignore next */ []));
16943
+ /**
16944
+ * Latest async-loaded `{ kind, component }` pair. Tagging the result with
16945
+ * its kind lets `resolvedComponent` filter stale loads structurally — no
16946
+ * monotonic token needed. When `addon.kind` changes mid-load, the prior
16947
+ * load still completes and writes here, but the computed ignores it
16948
+ * because the recorded kind no longer matches the current one.
16949
+ */
16950
+ asyncLoaded = signal(undefined, ...(ngDevMode ? [{ debugName: "asyncLoaded" }] : /* istanbul ignore next */ []));
16951
+ /**
16952
+ * Resolved kind component — composed declaratively from two sources of
16953
+ * truth: the registry's synchronous cache (warm hits, e.g., a second
16954
+ * `<df-addon-slot>` for an already-loaded kind) and the kind-tagged async
16955
+ * loader result. Reading `addon().kind` first makes the computed react to
16956
+ * addon swaps and naturally invalidate stale async-load results.
16957
+ */
16958
+ resolvedComponent = computed(() => {
16959
+ const kind = this.addon().kind;
16960
+ const cached = this.registry.getLoadedKindComponent(kind);
16961
+ if (cached)
16962
+ return cached;
16963
+ const loaded = this.asyncLoaded();
16964
+ return loaded?.kind === kind ? loaded.component : undefined;
16965
+ }, ...(ngDevMode ? [{ debugName: "resolvedComponent" }] : /* istanbul ignore next */ []));
16966
+ /** Inputs passed to the kind component — addon plus the wrapper-style host bag. */
16967
+ kindInputs = computed(() => ({
16968
+ addon: this.addon(),
16969
+ fieldInputs: this.fieldInputs(),
16970
+ }), ...(ngDevMode ? [{ debugName: "kindInputs" }] : /* istanbul ignore next */ []));
16971
+ constructor() {
16972
+ // Resolve `addon.hidden` outside any reactive context AND unconditionally —
16973
+ // the host-supplied signal is a perf hint, not a precondition. Keeping
16974
+ // `_resolvedHidden` always-fresh closes a race where `isHidden` could
16975
+ // return `false` between a host-`hidden` clear and the effect re-running.
16976
+ explicitEffect([this.addon], ([addon]) => {
16977
+ const h = addon.hidden;
16978
+ const valueKey = typeof h === 'object' && h !== null ? h : null;
16979
+ let resolved = valueKey ? this._hiddenByValue.get(valueKey) : undefined;
16980
+ if (!resolved) {
16981
+ resolved = resolveDynamicValue(addon.hidden, false, this.hostInjector);
16982
+ if (valueKey)
16983
+ this._hiddenByValue.set(valueKey, resolved);
16984
+ }
16985
+ this._resolvedHidden.set(resolved);
16986
+ });
16987
+ // Kick off async loads on cache miss. `resolvedComponent` filters stale
16988
+ // results by kind identity, so this effect is a pure side-effect dispatcher.
16989
+ explicitEffect([this.addon], ([addon]) => {
16990
+ const kind = addon.kind;
16991
+ if (this.registry.getLoadedKindComponent(kind))
16992
+ return; // sync path; computed picks it up
16993
+ this.registry
16994
+ .loadKindComponent(kind)
16995
+ .then((component) => this.asyncLoaded.set({ kind, component }))
16996
+ .catch((error) => {
16997
+ this.logger.warn(`Failed to load addon kind '${kind}': ${error instanceof Error ? error.message : String(error)}. ` +
16998
+ `Registered kinds: ${this.registry.getKindNames().join(', ') || '(none)'}.`);
16999
+ });
17000
+ });
17001
+ }
17002
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfAddonSlot, deps: [], target: i0.ɵɵFactoryTarget.Component });
17003
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: DfAddonSlot, isStandalone: true, selector: "df-addon-slot", inputs: { addon: { classPropertyName: "addon", publicName: "addon", isSignal: true, isRequired: true, transformFunction: null }, fieldInputs: { classPropertyName: "fieldInputs", publicName: "fieldInputs", isSignal: true, isRequired: false, transformFunction: null }, hidden: { classPropertyName: "hidden", publicName: "hidden", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.slot": "slotAttr()", "class": "className()", "style.display": "isHidden() ? \"none\" : null" } }, ngImport: i0, template: `
17004
+ @if (resolvedComponent(); as cmp) {
17005
+ <ng-container *ngComponentOutlet="cmp; inputs: kindInputs()" />
17006
+ }
17007
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17008
+ }
17009
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfAddonSlot, decorators: [{
17010
+ type: Component,
17011
+ args: [{
17012
+ selector: 'df-addon-slot',
17013
+ imports: [NgComponentOutlet],
17014
+ template: `
17015
+ @if (resolvedComponent(); as cmp) {
17016
+ <ng-container *ngComponentOutlet="cmp; inputs: kindInputs()" />
17017
+ }
17018
+ `,
17019
+ host: {
17020
+ '[attr.slot]': 'slotAttr()',
17021
+ '[class]': 'className()',
17022
+ '[style.display]': 'isHidden() ? "none" : null',
17023
+ },
17024
+ changeDetection: ChangeDetectionStrategy.OnPush,
17025
+ }]
17026
+ }], ctorParameters: () => [], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }], hidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "hidden", required: false }] }] } });
17027
+
17028
+ /**
17029
+ * Pipe that handles dynamic text resolution with support for static strings,
17030
+ * Observables, and Signals.
17031
+ *
17032
+ * Supports:
17033
+ * - Static strings (pass-through)
17034
+ * - Observables (subscribed internally)
17035
+ * - Signals (converted to Observable using toObservable for reactivity)
17036
+ *
17037
+ * @example
17038
+ * ```html
17039
+ * <!-- Static string -->
17040
+ * {{ 'Hello World' | dynamicText | async }}
17041
+ *
17042
+ * <!-- Observable -->
17043
+ * {{ transloco.selectTranslate('key') | dynamicText | async }}
17044
+ *
17045
+ * <!-- Signal -->
17046
+ * {{ myTextSignal | dynamicText | async }}
17047
+ * ```
17048
+ *
17049
+ * @public
17050
+ */
17051
+ class DynamicTextPipe {
17052
+ injector = inject(Injector);
17053
+ /**
17054
+ * Transforms dynamic text input into a resolved string value
17055
+ *
17056
+ * @param value - The dynamic text value to resolve
17057
+ * @returns The resolved string value as an Observable
17058
+ */
17059
+ transform(value) {
17060
+ if (isObservable(value)) {
17061
+ return value;
17062
+ }
17063
+ if (isSignal(value)) {
17064
+ return toObservable(value, { injector: this.injector });
17065
+ }
17066
+ return of(value || '');
17067
+ }
17068
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
17069
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, isStandalone: true, name: "dynamicText" });
17070
+ }
17071
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, decorators: [{
17072
+ type: Pipe,
17073
+ args: [{
17074
+ name: 'dynamicText',
17075
+ }]
17076
+ }] });
17077
+
17078
+ /**
17079
+ * Renderer for the universal `text` addon kind.
17080
+ *
17081
+ * Renders the addon's `text` (a `DynamicText`) inside a span. Decorative —
17082
+ * sets `aria-hidden="true"` on the host so screen readers skip it; users
17083
+ * who need announcement should use a `*-button` kind with `ariaLabel` instead.
17084
+ */
17085
+ class TextAddonComponent {
17086
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17087
+ /** Accepted for contract uniformity — `NgComponentOutlet` setInput is strict; every kind must declare it. */
17088
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17089
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TextAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17090
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.14", type: TextAddonComponent, isStandalone: true, selector: "df-text-addon", inputs: { addon: { classPropertyName: "addon", publicName: "addon", isSignal: true, isRequired: true, transformFunction: null }, fieldInputs: { classPropertyName: "fieldInputs", publicName: "fieldInputs", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.aria-hidden": "\"true\"" } }, ngImport: i0, template: `<span>{{ addon().text | dynamicText | async }}</span>`, isInline: true, dependencies: [{ kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: DynamicTextPipe, name: "dynamicText" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17091
+ }
17092
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TextAddonComponent, decorators: [{
17093
+ type: Component,
17094
+ args: [{
17095
+ selector: 'df-text-addon',
17096
+ imports: [AsyncPipe, DynamicTextPipe],
17097
+ template: `<span>{{ addon().text | dynamicText | async }}</span>`,
17098
+ host: {
17099
+ '[attr.aria-hidden]': '"true"',
17100
+ },
17101
+ changeDetection: ChangeDetectionStrategy.OnPush,
17102
+ }]
17103
+ }], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17104
+
17105
+ var textAddon_component = /*#__PURE__*/Object.freeze({
17106
+ __proto__: null,
17107
+ TextAddonComponent: TextAddonComponent
17108
+ });
17109
+
17110
+ /**
17111
+ * Renderer for the universal `template` addon kind.
17112
+ *
17113
+ * Resolves the addon's `templateKey` against {@link DF_FIELD_TEMPLATES}
17114
+ * (populated by `<df-dynamic-form>` from `<ng-template dfTemplate="...">`
17115
+ * children) and renders via `NgTemplateOutlet`. The wrapper-style host bag
17116
+ * (`fieldInputs`) is forwarded as the template's implicit context — author
17117
+ * the template as `<ng-template dfTemplate="..." let-fi>{{ fi.key }}</ng-template>`.
17118
+ *
17119
+ * If the key is unresolved, logs a warning and renders nothing — keeps the
17120
+ * form alive when a backend references a template the FE has not registered.
17121
+ */
17122
+ class TemplateAddonComponent {
17123
+ templates = inject(DF_FIELD_TEMPLATES, { optional: true });
17124
+ logger = inject(DynamicFormLogger);
17125
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17126
+ /** Forwarded by `df-addon-slot`; surfaced to the template as its implicit `$implicit` context. */
17127
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17128
+ // Pure computed: pure lookup, no side effects. Unresolved → null; the
17129
+ // `@if` in the template skips the outlet and renders nothing.
17130
+ template = computed(() => {
17131
+ const map = this.templates?.();
17132
+ const key = this.addon().templateKey;
17133
+ return map?.get(key) ?? null;
17134
+ }, ...(ngDevMode ? [{ debugName: "template" }] : /* istanbul ignore next */ []));
17135
+ /** Implicit context payload — template authors bind via `let-fi`. */
17136
+ outletContext = computed(() => ({ $implicit: this.fieldInputs() }), ...(ngDevMode ? [{ debugName: "outletContext" }] : /* istanbul ignore next */ []));
17137
+ constructor() {
17138
+ // Logging side effect lives in an effect, not the computed — keeps
17139
+ // the computed pure so signal-graph evaluations don't duplicate warnings.
17140
+ explicitEffect([this.template, this.addon], ([tpl, addon]) => {
17141
+ if (!tpl) {
17142
+ this.logger.warn(`Template addon: no <ng-template dfTemplate="${addon.templateKey}"> found. Did you project it as a child of <df-dynamic-form>?`);
17143
+ }
17144
+ });
17145
+ }
17146
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TemplateAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17147
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: TemplateAddonComponent, isStandalone: true, selector: "df-template-addon", inputs: { addon: { classPropertyName: "addon", publicName: "addon", isSignal: true, isRequired: true, transformFunction: null }, fieldInputs: { classPropertyName: "fieldInputs", publicName: "fieldInputs", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
17148
+ @if (template(); as tpl) {
17149
+ <ng-container *ngTemplateOutlet="tpl; context: outletContext()" />
17150
+ }
17151
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17152
+ }
17153
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TemplateAddonComponent, decorators: [{
17154
+ type: Component,
17155
+ args: [{
17156
+ selector: 'df-template-addon',
17157
+ imports: [NgTemplateOutlet],
17158
+ template: `
17159
+ @if (template(); as tpl) {
17160
+ <ng-container *ngTemplateOutlet="tpl; context: outletContext()" />
17161
+ }
17162
+ `,
17163
+ changeDetection: ChangeDetectionStrategy.OnPush,
17164
+ }]
17165
+ }], ctorParameters: () => [], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17166
+
17167
+ var templateAddon_component = /*#__PURE__*/Object.freeze({
17168
+ __proto__: null,
17169
+ TemplateAddonComponent: TemplateAddonComponent
17170
+ });
17171
+
17172
+ /**
17173
+ * Renderer for the universal `component` addon kind.
17174
+ *
17175
+ * Loads the user-supplied component lazily (`addon.component()`) and renders
17176
+ * it via `NgComponentOutlet` with `addon.inputs` forwarded as input bindings.
17177
+ * The wrapper-style `fieldInputs` bag is not propagated — user components
17178
+ * receive only what `addon.inputs` declares. Authors that need field state
17179
+ * should switch to the `template` kind or build a wrapper.
17180
+ *
17181
+ * @codeOnly The `component` loader is a function; this kind is dropped in
17182
+ * lenient validation when the config originated from JSON.
17183
+ */
17184
+ class ComponentAddonComponent {
17185
+ logger = inject(DynamicFormLogger);
17186
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17187
+ /** Accepted for contract uniformity — `NgComponentOutlet` setInput is strict; every kind must declare it. */
17188
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17189
+ inputs = computed(() => (this.addon().inputs ?? {}), ...(ngDevMode ? [{ debugName: "inputs" }] : /* istanbul ignore next */ []));
17190
+ /**
17191
+ * Latest async-loaded `{ addon, component }` pair. Tagging the result with
17192
+ * its source addon lets `resolvedComponent` filter stale loads structurally
17193
+ * — same kind-tagging pattern `DfAddonSlot` uses. When `addon` swaps
17194
+ * mid-load, the prior loader still completes and writes here, but the
17195
+ * computed ignores it because the recorded addon no longer matches the
17196
+ * current one.
17197
+ */
17198
+ asyncLoaded = signal(undefined, ...(ngDevMode ? [{ debugName: "asyncLoaded" }] : /* istanbul ignore next */ []));
17199
+ resolvedComponent = computed(() => {
17200
+ const current = this.addon();
17201
+ const loaded = this.asyncLoaded();
17202
+ return loaded?.addon === current ? loaded.component : undefined;
17203
+ }, ...(ngDevMode ? [{ debugName: "resolvedComponent" }] : /* istanbul ignore next */ []));
17204
+ constructor() {
17205
+ explicitEffect([this.addon], ([addon]) => {
17206
+ Promise.resolve(addon.component())
17207
+ .then((mod) => {
17208
+ const component = resolveDefaultExport(mod);
17209
+ if (component)
17210
+ this.asyncLoaded.set({ addon, component });
17211
+ })
17212
+ .catch((error) => {
17213
+ this.logger.warn(`Failed to load component addon (slot: '${addon.slot}'): ${error instanceof Error ? error.message : String(error)}`);
17214
+ });
17215
+ });
17216
+ }
17217
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17218
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: ComponentAddonComponent, isStandalone: true, selector: "df-component-addon", inputs: { addon: { classPropertyName: "addon", publicName: "addon", isSignal: true, isRequired: true, transformFunction: null }, fieldInputs: { classPropertyName: "fieldInputs", publicName: "fieldInputs", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
17219
+ @if (resolvedComponent(); as cmp) {
17220
+ <ng-container *ngComponentOutlet="cmp; inputs: inputs()" />
17221
+ }
17222
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17223
+ }
17224
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentAddonComponent, decorators: [{
17225
+ type: Component,
17226
+ args: [{
17227
+ selector: 'df-component-addon',
17228
+ imports: [NgComponentOutlet],
17229
+ template: `
17230
+ @if (resolvedComponent(); as cmp) {
17231
+ <ng-container *ngComponentOutlet="cmp; inputs: inputs()" />
17232
+ }
17233
+ `,
17234
+ changeDetection: ChangeDetectionStrategy.OnPush,
17235
+ }]
17236
+ }], ctorParameters: () => [], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17237
+
17238
+ var componentAddon_component = /*#__PURE__*/Object.freeze({
17239
+ __proto__: null,
17240
+ ComponentAddonComponent: ComponentAddonComponent
17241
+ });
17242
+
17243
+ /**
17244
+ * Apply a built-in preset action against the addon's host field. Shared
17245
+ * across all adapters — each adapter's `ADDON_PRESET_HANDLER` provider
17246
+ * delegates here.
17247
+ *
17248
+ * Presets that require adapter-specific UI mechanics (clipboard, type
17249
+ * override) are best-effort — they no-op with a warning when the necessary
17250
+ * collaborator isn't available (e.g., toggle-password-visibility outside an
17251
+ * input field).
17252
+ *
17253
+ * @param adapterLabel Used in warnings — `'Material'` / `'Bootstrap'` / etc.
17254
+ * @param fieldLabel Used in warnings — `'mat-input'` / `'bs-input'` / etc.
17255
+ */
17256
+ async function runPresetAction(preset, ctx, collaborators, adapterLabel, fieldLabel) {
17257
+ // Guard against orphan dispatches — none of the built-in presets do
17258
+ // anything useful without a host field, except `copy` and `paste` which
17259
+ // only need the clipboard API. The canonical discriminant per the
17260
+ // `AddonActionContext` discriminated union is `ctx.form` — non-null on
17261
+ // the field-bound variant, null on the orphan variant. `field.key` was
17262
+ // unreliable for nested-array scenarios where keys can legitimately be
17263
+ // empty strings.
17264
+ if (ctx.form === null && preset !== 'copy' && preset !== 'paste') {
17265
+ collaborators.logger.warn(`preset '${String(preset)}' fired without a host field context — ignoring.`);
17266
+ return;
17267
+ }
17268
+ switch (preset) {
17269
+ case 'clear': {
17270
+ // Preserve type semantics: strings clear to `''` (browser-friendly for
17271
+ // input.value), everything else clears to `undefined` (numeric / object
17272
+ // / date fields would otherwise become a string and break their type).
17273
+ const next = typeof ctx.value === 'string' ? '' : undefined;
17274
+ collaborators.fieldValueSetter?.(next);
17275
+ return;
17276
+ }
17277
+ case 'reset': {
17278
+ const defaultValue = collaborators.fieldDefaultValueGetter?.();
17279
+ collaborators.fieldValueSetter?.(defaultValue !== undefined ? defaultValue : typeof ctx.value === 'string' ? '' : undefined);
17280
+ return;
17281
+ }
17282
+ case 'paste': {
17283
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
17284
+ collaborators.logger.warn(`preset 'paste' requires a browser clipboard API (not available in this environment).`);
17285
+ return;
17286
+ }
17287
+ try {
17288
+ const text = await navigator.clipboard.readText();
17289
+ collaborators.fieldValueSetter?.(text);
17290
+ }
17291
+ catch (error) {
17292
+ collaborators.logger.warn(`preset 'paste' clipboard read failed: ${error instanceof Error ? error.message : String(error)}`);
17293
+ }
17294
+ return;
17295
+ }
17296
+ case 'copy': {
17297
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
17298
+ collaborators.logger.warn(`preset 'copy' requires a browser clipboard API (not available in this environment).`);
17299
+ return;
17300
+ }
17301
+ try {
17302
+ // Explicit null check — `String(null) === 'null'` would write the
17303
+ // literal string "null" to the clipboard.
17304
+ const v = ctx.value;
17305
+ await navigator.clipboard.writeText(v == null ? '' : String(v));
17306
+ }
17307
+ catch (error) {
17308
+ collaborators.logger.warn(`preset 'copy' clipboard write failed: ${error instanceof Error ? error.message : String(error)}`);
17309
+ }
17310
+ return;
17311
+ }
17312
+ case 'toggle-password-visibility': {
17313
+ const override = collaborators.typeOverride;
17314
+ if (!override) {
17315
+ collaborators.logger.warn(`preset 'toggle-password-visibility' has no effect outside a '${fieldLabel}' field.`);
17316
+ return;
17317
+ }
17318
+ // Refuse to flip non-password inputs. Re-check on EVERY dispatch (not
17319
+ // just when override is undefined) because `props().type` can change
17320
+ // reactively after the first toggle — e.g., 'password' → 'email'. Once
17321
+ // the override is touched, only the override is canonical for the
17322
+ // current visible/hidden state; the baseline still gates whether
17323
+ // toggling makes semantic sense for this field.
17324
+ const baseline = collaborators.baselineType?.();
17325
+ if (baseline !== undefined && baseline !== 'password') {
17326
+ collaborators.logger.warn(`preset 'toggle-password-visibility' refused: host '${fieldLabel}' has type '${baseline}', not 'password'.`);
17327
+ return;
17328
+ }
17329
+ const current = override();
17330
+ override.set(current === 'text' ? 'password' : 'text');
17331
+ return;
17332
+ }
17333
+ default:
17334
+ collaborators.logger.warn(`preset '${String(preset)}' is not implemented in the ${adapterLabel} adapter.`);
17335
+ }
17336
+ }
17337
+
17338
+ /**
17339
+ * Walk `FIELD_REGISTRY` and return every field type that opted into addons
17340
+ * (i.e., declared `addons` on its `FieldTypeDefinition`).
17341
+ *
17342
+ * Useful for tooling — admin UIs surfacing "which fields can carry addons?",
17343
+ * docs generators, MCP server cross-checks. Field types without an `addons`
17344
+ * registration ("Tier 3" — toggle, checkbox, radio, slider) are excluded.
17345
+ *
17346
+ * Must be called within an injection context.
17347
+ *
17348
+ * @example
17349
+ * ```typescript
17350
+ * @Component({ ... })
17351
+ * class FormBuilder {
17352
+ * protected readonly addonable = injectFieldsSupportingAddons();
17353
+ *
17354
+ * constructor() {
17355
+ * // -> [{ name: 'prime-input', slots: ['prefix', 'suffix'], allowedKinds: [...] }, ...]
17356
+ * }
17357
+ * }
17358
+ * ```
17359
+ */
17360
+ function injectFieldsSupportingAddons() {
17361
+ const registry = inject(FIELD_REGISTRY);
17362
+ const entries = [];
17363
+ for (const [name, def] of registry.entries()) {
17364
+ if (def.addons === undefined)
17365
+ continue;
17366
+ entries.push({ name, slots: def.addons.slots, allowedKinds: def.addons.allowedKinds });
17367
+ }
17368
+ return entries;
17369
+ }
17370
+
17371
+ /**
17372
+ * Register a custom addon kind with the form.
17373
+ *
17374
+ * The provided definition is added to `ADDON_KIND_REGISTRY` at form
17375
+ * initialisation, alongside the built-in `text` / `template` / `component`
17376
+ * kinds and any kinds contributed by adapter feature helpers
17377
+ * (`withPrimeNGAddons()` etc.).
17378
+ *
17379
+ * @example
17380
+ * ```typescript
17381
+ * provideDynamicForm(
17382
+ * ...withMaterialFields(),
17383
+ * withCustomAddon({
17384
+ * kind: 'lucide-icon',
17385
+ * // Match how field types register: a default-exported component lets
17386
+ * // `loadComponent` return the import promise directly.
17387
+ * loadComponent: () => import('./lucide-icon-addon.component'),
17388
+ * validate: (addon) => {
17389
+ * if (typeof addon.icon !== 'string') {
17390
+ * throw new Error('lucide-icon: icon string is required');
17391
+ * }
17392
+ * },
17393
+ * }),
17394
+ * );
17395
+ * ```
17396
+ *
17397
+ * Multiple `withCustomAddon(...)` calls are supported; later registrations
17398
+ * for the same kind override earlier ones (with a warning logged).
17399
+ */
17400
+ function withCustomAddon(definition) {
17401
+ return createFeature('addons', [
17402
+ {
17403
+ provide: ADDON_KIND_DEFINITIONS,
17404
+ useValue: definition,
17405
+ multi: true,
17406
+ },
17407
+ ]);
17408
+ }
17409
+
17410
+ /**
17411
+ * Register named action handlers reachable via JSON-driven addon configs.
17412
+ *
17413
+ * Once registered, a backend can ship
17414
+ * `{ kind: 'prime-button', actionRef: 'openProfile' }` and the FE will invoke
17415
+ * the corresponding handler at click time.
17416
+ *
17417
+ * Define handlers once and augment the registry from the same object:
17418
+ *
17419
+ * ```typescript
17420
+ * export const appActions = provideAddonActions({
17421
+ * openProfile: (ctx) => modalService.open(ProfileModal, { data: ctx.value }),
17422
+ * runWorkflow: (ctx) => workflowService.run(ctx.field.key, ctx.value),
17423
+ * });
17424
+ *
17425
+ * declare module '@ng-forge/dynamic-forms' {
17426
+ * // Pulls the keys straight off `appActions` — no manual list to keep in sync.
17427
+ * interface DynamicFormActionRegistry extends Record<NonNullable<typeof appActions['__handlerKeys']>, true> {}
17428
+ * }
17429
+ *
17430
+ * // Then in your bootstrap:
17431
+ * provideDynamicForm(...withPrimeNGFields(), appActions);
17432
+ * ```
17433
+ *
17434
+ * Multiple `provideAddonActions(...)` calls are merged; later names override
17435
+ * earlier ones for the same key.
17436
+ */
17437
+ function provideAddonActions(handlers) {
17438
+ return createFeature('addon-actions', [
17439
+ {
17440
+ provide: ADDON_ACTION_HANDLERS,
17441
+ useValue: handlers,
17442
+ multi: true,
17443
+ },
17444
+ ]);
17445
+ }
17446
+
17447
+ /**
17448
+ * Walk a `FormConfig`, validate every field's addons against the active
17449
+ * `FIELD_REGISTRY` and `ADDON_KIND_REGISTRY`, and return a sanitized copy
17450
+ * plus a list of dropped-addon warnings.
17451
+ *
17452
+ * Distinct from the INTERNAL `validateFormConfig` in
17453
+ * `utils/config-validation/config-validator.ts` (which throws on
17454
+ * structural errors like duplicate keys / unknown field types). This
17455
+ * helper is the PUBLIC, lenient addon-shape pass — renamed to
17456
+ * `sanitizeFormConfig` to avoid the name collision.
17457
+ *
17458
+ * Lenient by design: invalid addons are dropped (with warnings) rather than
17459
+ * thrown. The form keeps rendering even when the backend ships an addon the
17460
+ * FE doesn't understand.
17461
+ *
17462
+ * `<df-dynamic-form>` already runs this internally at form init — calling
17463
+ * the public helper AND passing the original config to the form will
17464
+ * double-emit warnings. Use this entry point only when you need the
17465
+ * sanitized config or warning list outside the form (e.g., admin-UI
17466
+ * diagnostics, build-time lints).
17467
+ *
17468
+ * Must be called within an Angular injection context — typically inside
17469
+ * an admin-UI component or a `runInInjectionContext()` block on the server.
17470
+ *
17471
+ * @example
17472
+ * ```typescript
17473
+ * @Component({ ... })
17474
+ * class AdminFormBuilder {
17475
+ * private readonly check = (cfg: FormConfig) => {
17476
+ * const { sanitized, warnings } = sanitizeFormConfig(cfg, { source: 'json' });
17477
+ * if (warnings.length > 0) {
17478
+ * this.diagnostics.set(warnings);
17479
+ * }
17480
+ * return sanitized;
17481
+ * };
17482
+ * }
17483
+ * ```
17484
+ */
17485
+ function sanitizeFormConfig(config, options = {}) {
17486
+ const fieldRegistry = inject(FIELD_REGISTRY);
17487
+ const kindRegistry = inject(ADDON_KIND_REGISTRY);
17488
+ const source = options.source ?? 'inline';
17489
+ const { fields, warnings } = walkAndValidateAddons((config.fields ?? []), fieldRegistry, kindRegistry, source);
17490
+ return {
17491
+ sanitized: { ...config, fields: fields },
17492
+ warnings,
17493
+ };
17494
+ }
17495
+ /**
17496
+ * Default warning sink. Pass a `DynamicFormLogger` to route through the
17497
+ * form's configured logger (respects `NoopLogger` opt-out); omit it to
17498
+ * fall back to `console.warn` for build-time / admin-UI use cases that
17499
+ * run outside an Angular injection context.
17500
+ */
17501
+ function logAddonWarnings(warnings, logger) {
17502
+ for (const w of warnings) {
17503
+ if (logger) {
17504
+ logger.warn(formatAddonWarning(w));
17505
+ }
17506
+ else {
17507
+ console.warn('[Dynamic Forms]', formatAddonWarning(w));
17508
+ }
17509
+ }
17510
+ }
17511
+ /**
17512
+ * DI-free variant of {@link sanitizeFormConfig} for tooling that lives
17513
+ * outside Angular's injector — build-time linters, MCP-side checks, CLI
17514
+ * scripts.
17515
+ *
17516
+ * Caller passes the registries explicitly; the result has the same shape
17517
+ * as the DI-bound version.
17518
+ */
17519
+ function sanitizeFormConfigPure(config, fieldRegistry, kindRegistry, options = {}) {
17520
+ const source = options.source ?? 'inline';
17521
+ const { fields, warnings } = walkAndValidateAddons((config.fields ?? []), fieldRegistry, kindRegistry, source);
17522
+ return { sanitized: { ...config, fields: fields }, warnings };
17523
+ }
17524
+
15518
17525
  function isValueField(field) {
15519
17526
  // `nullable: true` without an explicit `value` is still a value-bearing declaration:
15520
17527
  // the field resolves to `null` at runtime. Treating it as a value field keeps
@@ -15523,7 +17530,7 @@ function isValueField(field) {
15523
17530
  }
15524
17531
 
15525
17532
  function isCheckedField(field) {
15526
- return field.type === 'checkbox';
17533
+ return field.type === 'checkbox' || field.type === 'toggle';
15527
17534
  }
15528
17535
 
15529
17536
  /**
@@ -16029,56 +18036,6 @@ function applyMetaToElement(element, meta, previouslyApplied) {
16029
18036
  return newApplied;
16030
18037
  }
16031
18038
 
16032
- /**
16033
- * Pipe that handles dynamic text resolution with support for static strings,
16034
- * Observables, and Signals.
16035
- *
16036
- * Supports:
16037
- * - Static strings (pass-through)
16038
- * - Observables (subscribed internally)
16039
- * - Signals (converted to Observable using toObservable for reactivity)
16040
- *
16041
- * @example
16042
- * ```html
16043
- * <!-- Static string -->
16044
- * {{ 'Hello World' | dynamicText | async }}
16045
- *
16046
- * <!-- Observable -->
16047
- * {{ transloco.selectTranslate('key') | dynamicText | async }}
16048
- *
16049
- * <!-- Signal -->
16050
- * {{ myTextSignal | dynamicText | async }}
16051
- * ```
16052
- *
16053
- * @public
16054
- */
16055
- class DynamicTextPipe {
16056
- injector = inject(Injector);
16057
- /**
16058
- * Transforms dynamic text input into a resolved string value
16059
- *
16060
- * @param value - The dynamic text value to resolve
16061
- * @returns The resolved string value as an Observable
16062
- */
16063
- transform(value) {
16064
- if (isObservable(value)) {
16065
- return value;
16066
- }
16067
- if (isSignal(value)) {
16068
- return toObservable(value, { injector: this.injector });
16069
- }
16070
- return of(value || '');
16071
- }
16072
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
16073
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, isStandalone: true, name: "dynamicText" });
16074
- }
16075
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, decorators: [{
16076
- type: Pipe,
16077
- args: [{
16078
- name: 'dynamicText',
16079
- }]
16080
- }] });
16081
-
16082
18039
  /**
16083
18040
  * Interpolates {{param}} placeholders in message with error values
16084
18041
  *
@@ -16210,5 +18167,5 @@ function extractErrorParams(error) {
16210
18167
  * Generated bundle index. Do not edit.
16211
18168
  */
16212
18169
 
16213
- export { SubmitEvent as $, ARRAY_CONTEXT as A, BUILT_IN_FIELDS as B, CONTAINER_FIELD_PROCESSORS as C, DynamicFormLogger as D, EventBus as E, FIELD_SIGNAL_CONTEXT as F, DEFAULT_VALIDATION_MESSAGES as G, DynamicForm as H, DynamicFormError as I, EventDispatcher as J, FIELD_REGISTRY as K, FORM_OPTIONS as L, FormClearEvent as M, FormResetEvent as N, INITIALIZATION_TIMEOUT_MS as O, InsertArrayItemEvent as P, MoveArrayItemEvent as Q, RootFormRegistryService as R, NextPageEvent as S, NoopLogger as T, PageChangeEvent as U, PopArrayItemEvent as V, WRAPPER_AUTO_ASSOCIATIONS as W, PrependArrayItemEvent as X, PreviousPageEvent as Y, RemoveAtIndexEvent as Z, ShiftArrayItemEvent as _, DEFAULT_WRAPPERS as a, WRAPPER_REGISTRY as a0, applyMetaToElement as a1, applyValidator as a2, applyValidators as a3, arrayEvent as a4, arrayFieldMapper as a5, baseFieldMapper as a6, buildBaseInputs as a7, containerFieldMapper as a8, createField as a9, resolveNextButtonDisabled as aA, resolveNonFieldDisabled as aB, resolveNonFieldHidden as aC, resolveSubmitButtonDisabled as aD, resolveTokens as aE, rowFieldMapper as aF, textFieldMapper as aG, toReadonlyFieldTree as aH, withEventFormValue as aI, withLoggerConfig as aJ, withPreviousValue as aK, withValidationExecutionDefaults as aL, withValueExclusionDefaults as aM, wrapperProps as aN, createWrappers as aa, evaluateNonFieldDisabled as ab, evaluateNonFieldHidden as ac, field as ad, formConfig as ae, getArrayLength as af, groupFieldMapper as ag, hasFormValue as ah, interpolateParams as ai, isArrayField as aj, isCheckedField as ak, isContainerField as al, isContainerTypedField as am, isDisplayOnlyField as an, isFormStateCondition as ao, isLeafField as ap, isPageField as aq, isRowField as ar, isSimplifiedArrayField as as, isValueBearingField as at, isValueField as au, isWrapperTypeDefinition as av, isWrappersBundle as aw, omit as ax, pageFieldMapper as ay, provideDynamicForm as az, isSameWrapperChain as b, createFieldResolutionPipe as c, derivedFromDeferred as d, createWrapperChainController as e, DfFieldOutlet as f, isEqual as g, createSchemaFromFields as h, injectFieldRegistry as i, createRenderReadySignal as j, ARRAY_TEMPLATE_REGISTRY as k, ARRAY_ITEM_ID_GENERATOR as l, mapFieldToInputs as m, getNormalizedArrayMetadata as n, getFieldValueHandling as o, getFieldDefaultValue as p, isGroupField as q, resolveWrappers as r, createArrayItemIdGenerator as s, ComponentInitializedEvent as t, DynamicTextPipe as u, validatePageNesting as v, dynamicTextToObservable as w, AppendArrayItemEvent as x, ConsoleLogger as y, DEFAULT_PROPS as z };
16214
- //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-QdV2vcCx.mjs.map
18170
+ export { arrayFieldMapper as $, ADDON_ACTION_HANDLERS as A, BUILT_IN_FIELDS as B, CONTAINER_FIELD_PROCESSORS as C, DEFAULT_PROPS as D, EventBus as E, FIELD_REGISTRY as F, GROUP_CONTEXT as G, NoopLogger as H, INITIALIZATION_TIMEOUT_MS as I, PopArrayItemEvent as J, PrependArrayItemEvent as K, PreviousPageEvent as L, MoveArrayItemEvent as M, NextPageEvent as N, RootFormRegistryService as O, PageChangeEvent as P, SubmitEvent as Q, RemoveAtIndexEvent as R, ShiftArrayItemEvent as S, TemplateAddonComponent as T, TextAddonComponent as U, WRAPPER_REGISTRY as V, WRAPPER_AUTO_ASSOCIATIONS as W, applyMetaToElement as X, applyValidator as Y, applyValidators as Z, arrayEvent as _, ADDON_ACTION_REGISTRY as a, textFieldMapper as a$, baseFieldMapper as a0, buildBaseInputs as a1, containerFieldMapper as a2, createField as a3, createFieldResolutionPipe as a4, createHiddenSignal as a5, createRenderReadySignal as a6, createSchemaFromFields as a7, createWrapperChainController as a8, createWrappers as a9, isGroupField as aA, isLeafField as aB, isPageField as aC, isRowField as aD, isSameWrapperChain as aE, isSimplifiedArrayField as aF, isValueBearingField as aG, isValueField as aH, isWrapperTypeDefinition as aI, isWrappersBundle as aJ, logAddonWarnings as aK, mapFieldToInputs as aL, omit as aM, pageFieldMapper as aN, provideAddonActions as aO, provideDynamicForm as aP, resolveDynamicValue as aQ, resolveNextButtonDisabled as aR, resolveNonFieldDisabled as aS, resolveNonFieldHidden as aT, resolveSubmitButtonDisabled as aU, resolveTokens as aV, resolveWrappers as aW, rowFieldMapper as aX, runPresetAction as aY, sanitizeFormConfig as aZ, sanitizeFormConfigPure as a_, derivedFromDeferred as aa, dynamicTextToObservable as ab, evaluateNonFieldDisabled as ac, evaluateNonFieldHidden as ad, field as ae, formConfig as af, formatAddonWarning as ag, getArrayLength as ah, getFieldDefaultValue as ai, getFieldValueHandling as aj, getNormalizedArrayMetadata as ak, groupFieldMapper as al, hasFormValue as am, injectAddonKindRegistry as an, injectFieldRegistry as ao, injectFieldSignalContext as ap, injectFieldsSupportingAddons as aq, interpolateParams as ar, isArrayField as as, isCheckedField as at, isContainerField as au, isContainerTypedField as av, isDisplayOnlyField as aw, isEqual as ax, isFieldBoundContext as ay, isFormStateCondition as az, ADDON_KIND_COMPONENT_CACHE as b, toReadonlyFieldTree as b0, validateFieldAddons as b1, validatePageNesting as b2, walkAndValidateAddons as b3, withCustomAddon as b4, withEventFormValue as b5, withLegacyStatusClasses as b6, withLoggerConfig as b7, withPreviousValue as b8, withValidationExecutionDefaults as b9, withValueExclusionDefaults as ba, wrapperProps as bb, writeToFieldValue as bc, ADDON_KIND_DEFINITIONS as c, ADDON_KIND_REGISTRY as d, ARRAY_CONTEXT as e, AppendArrayItemEvent as f, ArrayItemRegistryService as g, ComponentAddonComponent as h, ComponentInitializedEvent as i, ConsoleLogger as j, DEFAULT_VALIDATION_MESSAGES as k, DEFAULT_WRAPPERS as l, DF_FIELD_TEMPLATES as m, DfAddonSlot as n, DfFieldOutlet as o, DfTemplate as p, DynamicForm as q, DynamicFormError as r, DynamicFormLogger as s, DynamicTextPipe as t, EventDispatcher as u, FIELD_SIGNAL_CONTEXT as v, FORM_OPTIONS as w, FormClearEvent as x, FormResetEvent as y, InsertArrayItemEvent as z };
18171
+ //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-Be1Lk0qR.mjs.map