@ng-forge/dynamic-forms 0.9.0-next.9 → 0.10.0-next.1

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-us7mQtVQ.mjs → ng-forge-dynamic-forms-array-field.component-GiaVHO9N.mjs} +171 -73
  3. package/fesm2022/ng-forge-dynamic-forms-array-field.component-GiaVHO9N.mjs.map +1 -0
  4. package/fesm2022/{ng-forge-dynamic-forms-container-field.component-BCIQ6_Mm.mjs → ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs} +12 -8
  5. package/fesm2022/ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs.map +1 -0
  6. package/fesm2022/{ng-forge-dynamic-forms-container-utils-CzZlFNA0.mjs → ng-forge-dynamic-forms-container-utils-BS4d0cCk.mjs} +2 -2
  7. package/fesm2022/{ng-forge-dynamic-forms-container-utils-CzZlFNA0.mjs.map → ng-forge-dynamic-forms-container-utils-BS4d0cCk.mjs.map} +1 -1
  8. package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-Dhlf6UxN.mjs → ng-forge-dynamic-forms-css-wrapper.component-CnJgFWLB.mjs} +5 -5
  9. package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-CnJgFWLB.mjs.map +1 -0
  10. package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-DqQ8BA4W.mjs → ng-forge-dynamic-forms-emit-initialization-3PyXVwsn.mjs} +2 -2
  11. package/fesm2022/{ng-forge-dynamic-forms-emit-initialization-DqQ8BA4W.mjs.map → ng-forge-dynamic-forms-emit-initialization-3PyXVwsn.mjs.map} +1 -1
  12. package/fesm2022/{ng-forge-dynamic-forms-group-field.component-DKcoco0G.mjs → ng-forge-dynamic-forms-group-field.component-BJ8j7Sh0.mjs} +36 -12
  13. package/fesm2022/ng-forge-dynamic-forms-group-field.component-BJ8j7Sh0.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-CfbmSRM_.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms--PuL0AKc.mjs} +3031 -644
  17. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms--PuL0AKc.mjs.map +1 -0
  18. package/fesm2022/ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs +114 -0
  19. package/fesm2022/ng-forge-dynamic-forms-page-field.component-Bz2RpleR.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-8C1-oyU_.mjs → ng-forge-dynamic-forms-text-field.component-BGw6T-4h.mjs} +5 -5
  23. package/fesm2022/{ng-forge-dynamic-forms-text-field.component-8C1-oyU_.mjs.map → ng-forge-dynamic-forms-text-field.component-BGw6T-4h.mjs.map} +1 -1
  24. package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
  25. package/package.json +1 -1
  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-us7mQtVQ.mjs.map +0 -1
  29. package/fesm2022/ng-forge-dynamic-forms-container-field.component-BCIQ6_Mm.mjs.map +0 -1
  30. package/fesm2022/ng-forge-dynamic-forms-css-wrapper.component-Dhlf6UxN.mjs.map +0 -1
  31. package/fesm2022/ng-forge-dynamic-forms-group-field.component-DKcoco0G.mjs.map +0 -1
  32. package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-CfbmSRM_.mjs.map +0 -1
  33. package/fesm2022/ng-forge-dynamic-forms-page-field.component-DqWAcwhG.mjs +0 -110
  34. package/fesm2022/ng-forge-dynamic-forms-page-field.component-DqWAcwhG.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, merge, pairwise, combineLatestWith, auditTime, scheduled, queueScheduler, 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
 
@@ -1919,10 +2010,16 @@ var TokenType;
1919
2010
  // Punctuation
1920
2011
  TokenType["DOT"] = "DOT";
1921
2012
  TokenType["COMMA"] = "COMMA";
2013
+ TokenType["COLON"] = "COLON";
1922
2014
  TokenType["LPAREN"] = "LPAREN";
1923
2015
  TokenType["RPAREN"] = "RPAREN";
1924
2016
  TokenType["LBRACKET"] = "LBRACKET";
1925
2017
  TokenType["RBRACKET"] = "RBRACKET";
2018
+ TokenType["LBRACE"] = "LBRACE";
2019
+ TokenType["RBRACE"] = "RBRACE";
2020
+ TokenType["ARROW"] = "ARROW";
2021
+ TokenType["QUESTION"] = "QUESTION";
2022
+ TokenType["OPTIONAL_DOT"] = "OPTIONAL_DOT";
1926
2023
  // Special
1927
2024
  TokenType["EOF"] = "EOF";
1928
2025
  })(TokenType || (TokenType = {}));
@@ -2102,6 +2199,12 @@ class Tokenizer {
2102
2199
  case '||':
2103
2200
  this.position += 2;
2104
2201
  return { type: TokenType.OR, value: '||', position: start };
2202
+ case '=>':
2203
+ this.position += 2;
2204
+ return { type: TokenType.ARROW, value: '=>', position: start };
2205
+ case '?.':
2206
+ this.position += 2;
2207
+ return { type: TokenType.OPTIONAL_DOT, value: '?.', position: start };
2105
2208
  }
2106
2209
  // Single-character operators and punctuation
2107
2210
  this.position++;
@@ -2126,6 +2229,8 @@ class Tokenizer {
2126
2229
  return { type: TokenType.DOT, value: '.', position: start };
2127
2230
  case ',':
2128
2231
  return { type: TokenType.COMMA, value: ',', position: start };
2232
+ case ':':
2233
+ return { type: TokenType.COLON, value: ':', position: start };
2129
2234
  case '(':
2130
2235
  return { type: TokenType.LPAREN, value: '(', position: start };
2131
2236
  case ')':
@@ -2134,6 +2239,12 @@ class Tokenizer {
2134
2239
  return { type: TokenType.LBRACKET, value: '[', position: start };
2135
2240
  case ']':
2136
2241
  return { type: TokenType.RBRACKET, value: ']', position: start };
2242
+ case '{':
2243
+ return { type: TokenType.LBRACE, value: '{', position: start };
2244
+ case '}':
2245
+ return { type: TokenType.RBRACE, value: '}', position: start };
2246
+ case '?':
2247
+ return { type: TokenType.QUESTION, value: '?', position: start };
2137
2248
  default:
2138
2249
  throw new ExpressionParserError(`Unexpected character: ${char}`, start, this.expression);
2139
2250
  }
@@ -2169,7 +2280,88 @@ class Parser {
2169
2280
  return result;
2170
2281
  }
2171
2282
  parseExpression() {
2172
- return this.parseLogicalOr();
2283
+ // Try arrow function first — it has the lowest precedence and would otherwise
2284
+ // be ambiguous with grouped expressions / bare identifiers.
2285
+ const arrow = this.tryParseArrowFunction();
2286
+ if (arrow)
2287
+ return arrow;
2288
+ return this.parseConditional();
2289
+ }
2290
+ /**
2291
+ * Ternary `test ? consequent : alternate` — right-associative, sits between
2292
+ * the arrow-function check and logical-or in precedence.
2293
+ */
2294
+ parseConditional() {
2295
+ const test = this.parseLogicalOr();
2296
+ if (!this.match(TokenType.QUESTION))
2297
+ return test;
2298
+ const consequent = this.parseExpression();
2299
+ this.consume(TokenType.COLON, 'Expected ":" between ternary branches');
2300
+ const alternate = this.parseExpression();
2301
+ return { type: 'Conditional', test, consequent, alternate };
2302
+ }
2303
+ /**
2304
+ * Attempts to parse an arrow function. Returns null and leaves position
2305
+ * unchanged if the next tokens don't form an arrow function head.
2306
+ *
2307
+ * Supported shapes:
2308
+ * x => body
2309
+ * () => body
2310
+ * (x) => body
2311
+ * (x, y, ...) => body
2312
+ *
2313
+ * Body returning an object literal must be wrapped in parens: `d => ({...})`.
2314
+ */
2315
+ tryParseArrowFunction() {
2316
+ const savedPos = this.current;
2317
+ // Single unparenthesized param: IDENTIFIER =>
2318
+ if (this.check(TokenType.IDENTIFIER) && this.checkAt(this.current + 1, TokenType.ARROW)) {
2319
+ const param = this.advance().value;
2320
+ this.advance(); // consume =>
2321
+ const body = this.parseExpression();
2322
+ return { type: 'ArrowFunction', params: [param], body };
2323
+ }
2324
+ // Parenthesized params: ( ) => or ( id (, id)* ) =>
2325
+ if (this.check(TokenType.LPAREN)) {
2326
+ this.advance(); // tentatively consume (
2327
+ const params = [];
2328
+ if (this.check(TokenType.RPAREN)) {
2329
+ this.advance(); // )
2330
+ }
2331
+ else if (this.check(TokenType.IDENTIFIER)) {
2332
+ params.push(this.advance().value);
2333
+ while (this.check(TokenType.COMMA)) {
2334
+ this.advance(); // ,
2335
+ if (!this.check(TokenType.IDENTIFIER)) {
2336
+ this.current = savedPos;
2337
+ return null;
2338
+ }
2339
+ params.push(this.advance().value);
2340
+ }
2341
+ if (!this.check(TokenType.RPAREN)) {
2342
+ this.current = savedPos;
2343
+ return null;
2344
+ }
2345
+ this.advance(); // )
2346
+ }
2347
+ else {
2348
+ this.current = savedPos;
2349
+ return null;
2350
+ }
2351
+ if (!this.check(TokenType.ARROW)) {
2352
+ this.current = savedPos;
2353
+ return null;
2354
+ }
2355
+ this.advance(); // =>
2356
+ const body = this.parseExpression();
2357
+ return { type: 'ArrowFunction', params, body };
2358
+ }
2359
+ return null;
2360
+ }
2361
+ checkAt(index, type) {
2362
+ if (index < 0 || index >= this.tokens.length)
2363
+ return false;
2364
+ return this.tokens[index].type === type;
2173
2365
  }
2174
2366
  parseLogicalOr() {
2175
2367
  let node = this.parseLogicalAnd();
@@ -2244,6 +2436,32 @@ class Parser {
2244
2436
  const property = this.advance().value;
2245
2437
  node = { type: 'MemberAccess', object: node, property };
2246
2438
  }
2439
+ else if (this.match(TokenType.OPTIONAL_DOT)) {
2440
+ // Optional chain — supports `?.prop`, `?.[expr]`, and `?.(args)`.
2441
+ if (this.match(TokenType.LBRACKET)) {
2442
+ const property = this.parseExpression();
2443
+ this.consume(TokenType.RBRACKET, 'Expected "]" after computed property');
2444
+ node = { type: 'ComputedMemberAccess', object: node, property, optional: true };
2445
+ }
2446
+ else if (this.match(TokenType.LPAREN)) {
2447
+ const args = this.parseArgumentList();
2448
+ this.consume(TokenType.RPAREN, 'Expected ")" after arguments');
2449
+ node = { type: 'CallExpression', callee: node, arguments: args, optional: true };
2450
+ }
2451
+ else if (this.check(TokenType.IDENTIFIER)) {
2452
+ const property = this.advance().value;
2453
+ node = { type: 'MemberAccess', object: node, property, optional: true };
2454
+ }
2455
+ else {
2456
+ throw new ExpressionParserError('Expected property name, "[", or "(" after "?."', this.peek().position, this.expression);
2457
+ }
2458
+ }
2459
+ else if (this.match(TokenType.LBRACKET)) {
2460
+ // Computed member access: obj[expr]
2461
+ const property = this.parseExpression();
2462
+ this.consume(TokenType.RBRACKET, 'Expected "]" after computed property');
2463
+ node = { type: 'ComputedMemberAccess', object: node, property };
2464
+ }
2247
2465
  else if (this.match(TokenType.LPAREN)) {
2248
2466
  // Function call: func()
2249
2467
  const args = this.parseArgumentList();
@@ -2282,6 +2500,12 @@ class Parser {
2282
2500
  this.consume(TokenType.RBRACKET, 'Expected "]" after array elements');
2283
2501
  return { type: 'ArrayLiteral', elements };
2284
2502
  }
2503
+ // Object literal
2504
+ if (this.match(TokenType.LBRACE)) {
2505
+ const properties = this.parseObjectProperties();
2506
+ this.consume(TokenType.RBRACE, 'Expected "}" after object properties');
2507
+ return { type: 'ObjectLiteral', properties };
2508
+ }
2285
2509
  // Identifier
2286
2510
  if (this.match(TokenType.IDENTIFIER)) {
2287
2511
  return { type: 'Identifier', name: this.previous().value };
@@ -2312,6 +2536,28 @@ class Parser {
2312
2536
  }
2313
2537
  return elements;
2314
2538
  }
2539
+ parseObjectProperties() {
2540
+ const properties = [];
2541
+ if (this.check(TokenType.RBRACE)) {
2542
+ return properties;
2543
+ }
2544
+ do {
2545
+ // Trailing comma support: `{ a: 1, }`
2546
+ if (this.check(TokenType.RBRACE))
2547
+ break;
2548
+ let key;
2549
+ if (this.check(TokenType.IDENTIFIER) || this.check(TokenType.STRING)) {
2550
+ key = this.advance().value;
2551
+ }
2552
+ else {
2553
+ throw new ExpressionParserError(`Expected property name (identifier or string) in object literal, got '${this.peek().value}'`, this.peek().position, this.expression);
2554
+ }
2555
+ this.consume(TokenType.COLON, 'Expected ":" after property name in object literal');
2556
+ const value = this.parseExpression();
2557
+ properties.push({ key, value });
2558
+ } while (this.match(TokenType.COMMA));
2559
+ return properties;
2560
+ }
2315
2561
  match(...types) {
2316
2562
  for (const type of types) {
2317
2563
  if (this.check(type)) {
@@ -2486,14 +2732,22 @@ class Evaluator {
2486
2732
  return this.evaluateIdentifier(node.name);
2487
2733
  case 'MemberAccess':
2488
2734
  return this.evaluateMemberAccess(node);
2735
+ case 'ComputedMemberAccess':
2736
+ return this.evaluateComputedMemberAccess(node);
2489
2737
  case 'BinaryOp':
2490
2738
  return this.evaluateBinaryOp(node);
2491
2739
  case 'UnaryOp':
2492
2740
  return this.evaluateUnaryOp(node);
2493
2741
  case 'CallExpression':
2494
2742
  return this.evaluateCallExpression(node);
2743
+ case 'Conditional':
2744
+ return this.evaluate(node.test) ? this.evaluate(node.consequent) : this.evaluate(node.alternate);
2495
2745
  case 'ArrayLiteral':
2496
2746
  return this.evaluateArrayLiteral(node);
2747
+ case 'ObjectLiteral':
2748
+ return this.evaluateObjectLiteral(node);
2749
+ case 'ArrowFunction':
2750
+ return this.evaluateArrowFunction(node);
2497
2751
  default:
2498
2752
  throw new ExpressionParserError(`Unknown node type: ${node.type}`, 0, this.expression);
2499
2753
  }
@@ -2519,6 +2773,27 @@ class Evaluator {
2519
2773
  }
2520
2774
  return undefined;
2521
2775
  }
2776
+ evaluateComputedMemberAccess(node) {
2777
+ const obj = this.evaluate(node.object);
2778
+ if (obj === null || obj === undefined) {
2779
+ // Both `obj?.[expr]` and `obj[expr]` short-circuit on null/undefined —
2780
+ // matches the safe-access behavior of dotted member access above.
2781
+ return undefined;
2782
+ }
2783
+ const rawKey = this.evaluate(node.property);
2784
+ if (rawKey === null || rawKey === undefined)
2785
+ return undefined;
2786
+ const key = String(rawKey);
2787
+ // Same blocklist as dotted access — `obj['__proto__']` must be rejected
2788
+ // even though the property name was computed.
2789
+ if (BLOCKED_PROPERTIES.has(key)) {
2790
+ throw new ExpressionParserError(`Property "${key}" is not accessible for security reasons`, 0, this.expression);
2791
+ }
2792
+ if (typeof obj === 'object' || typeof obj === 'string' || typeof obj === 'number') {
2793
+ return obj[key];
2794
+ }
2795
+ return undefined;
2796
+ }
2522
2797
  evaluateBinaryOp(node) {
2523
2798
  // Handle short-circuit evaluation for logical operators
2524
2799
  if (node.operator === '&&') {
@@ -2583,11 +2858,21 @@ class Evaluator {
2583
2858
  }
2584
2859
  }
2585
2860
  evaluateCallExpression(node) {
2586
- // Only allow method calls (member access), not arbitrary function calls
2861
+ // Only allow method calls (member access). Computed access (`obj['method']()`)
2862
+ // is also rejected — the security model whitelists method names statically.
2587
2863
  if (node.callee.type !== 'MemberAccess') {
2588
2864
  throw new ExpressionParserError('Only method calls are allowed, not arbitrary function calls', 0, this.expression);
2589
2865
  }
2590
2866
  const obj = this.evaluate(node.callee.object);
2867
+ // Calling a method on a nullish receiver silently returns undefined —
2868
+ // consistent with how dotted member access (`obj.prop`) treats nullish
2869
+ // receivers above. This means `a?.b()`, `a?.b.c()`, and `nullable.method()`
2870
+ // all short-circuit to undefined rather than tripping the whitelist check
2871
+ // with a misleading "method not allowed" error. The whitelist still
2872
+ // enforces strictly on non-nullish receivers.
2873
+ if (obj === null || obj === undefined) {
2874
+ return undefined;
2875
+ }
2591
2876
  const methodName = node.callee.property;
2592
2877
  // Block access to dangerous properties (must check before method call)
2593
2878
  if (BLOCKED_PROPERTIES.has(methodName)) {
@@ -2610,6 +2895,28 @@ class Evaluator {
2610
2895
  evaluateArrayLiteral(node) {
2611
2896
  return node.elements.map((element) => this.evaluate(element));
2612
2897
  }
2898
+ evaluateObjectLiteral(node) {
2899
+ const result = {};
2900
+ for (const { key, value } of node.properties) {
2901
+ if (BLOCKED_PROPERTIES.has(key)) {
2902
+ throw new ExpressionParserError(`Property "${key}" is not allowed in object literals for security reasons`, 0, this.expression);
2903
+ }
2904
+ result[key] = this.evaluate(value);
2905
+ }
2906
+ return result;
2907
+ }
2908
+ evaluateArrowFunction(node) {
2909
+ const parentScope = this.scope;
2910
+ const expression = this.expression;
2911
+ return (...args) => {
2912
+ const childScope = { ...parentScope };
2913
+ for (let i = 0; i < node.params.length; i++) {
2914
+ childScope[node.params[i]] = args[i];
2915
+ }
2916
+ const childEvaluator = new Evaluator(childScope, expression);
2917
+ return childEvaluator.evaluate(node.body);
2918
+ };
2919
+ }
2613
2920
  isMethodSafe(obj, methodName) {
2614
2921
  if (obj === null || obj === undefined) {
2615
2922
  return false;
@@ -2744,16 +3051,25 @@ function evaluateCondition(expression, context) {
2744
3051
  case 'javascript':
2745
3052
  return evaluateJavaScriptExpression(expression, context);
2746
3053
  case 'custom': {
2747
- const customFn = context.customFunctions?.[expression.functionName];
3054
+ // Read both keys via cast — the XOR type narrows to `never` when both
3055
+ // would be truthy, but JSON-loaded configs can produce that state at
3056
+ // runtime. We accept it here, warn, and let inline win.
3057
+ const bothSet = expression;
3058
+ const inlineFn = bothSet.fn;
3059
+ const registeredName = bothSet.functionName;
3060
+ if (inlineFn && registeredName) {
3061
+ context.logger.warn('Both "fn" and "functionName" are set on custom condition. Inline "fn" takes precedence; "functionName" is ignored.');
3062
+ }
3063
+ const customFn = inlineFn ?? (registeredName ? context.customFunctions?.[registeredName] : undefined);
2748
3064
  if (!customFn) {
2749
- context.logger.error('Custom function not found:', expression.functionName);
3065
+ context.logger.error('Custom function not found:', registeredName);
2750
3066
  return false;
2751
3067
  }
2752
3068
  try {
2753
3069
  return !!customFn(context);
2754
3070
  }
2755
3071
  catch (error) {
2756
- context.logger.error('Error executing custom function:', expression.functionName, error);
3072
+ context.logger.error('Error executing custom function:', registeredName ?? '<inline>', error);
2757
3073
  return false;
2758
3074
  }
2759
3075
  }
@@ -2762,7 +3078,7 @@ function evaluateCondition(expression, context) {
2762
3078
  case 'or':
2763
3079
  return evaluateOrCondition(expression, context);
2764
3080
  case 'http':
2765
- context.logger.warn('[Dynamic Forms] HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
3081
+ context.logger.warn('HTTP conditions are resolved asynchronously via createHttpConditionLogicFunction(). ' +
2766
3082
  'When used inside and/or composites, the HTTP result is not available synchronously. Returning false.');
2767
3083
  return false;
2768
3084
  case 'async':
@@ -3298,10 +3614,10 @@ class FunctionRegistryService {
3298
3614
  this.clearAsyncValidators();
3299
3615
  this.clearHttpValidators();
3300
3616
  }
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 });
3617
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
3618
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService });
3303
3619
  }
3304
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FunctionRegistryService, decorators: [{
3620
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FunctionRegistryService, decorators: [{
3305
3621
  type: Injectable
3306
3622
  }] });
3307
3623
 
@@ -3752,10 +4068,10 @@ class FieldContextRegistryService {
3752
4068
  },
3753
4069
  };
3754
4070
  }
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 });
4071
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4072
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService });
3757
4073
  }
3758
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FieldContextRegistryService, decorators: [{
4074
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FieldContextRegistryService, decorators: [{
3759
4075
  type: Injectable
3760
4076
  }] });
3761
4077
 
@@ -4110,8 +4426,8 @@ class PageOrchestratorComponent {
4110
4426
  }
4111
4427
  return false;
4112
4428
  }
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: [
4429
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PageOrchestratorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4430
+ 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
4431
  {
4116
4432
  provide: FIELD_SIGNAL_CONTEXT,
4117
4433
  useFactory: (orchestrator) => orchestrator.extendedFieldSignalContext(),
@@ -4141,9 +4457,9 @@ class PageOrchestratorComponent {
4141
4457
  }
4142
4458
  }
4143
4459
  }
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-DqWAcwhG.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-DqWAcwhG.mjs').then(m => m.default)]] });
4460
+ `, isInline: true, styles: [":host{display:block;position:relative;width:100%}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, deferBlockDependencies: [() => [import('./ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs').then(m => m.default)], () => [import('./ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs').then(m => m.default)]] });
4145
4461
  }
4146
- i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.8", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-DqWAcwhG.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
4462
+ i0.ɵɵngDeclareClassMetadataAsync({ minVersion: "18.0.0", version: "21.2.14", ngImport: i0, type: PageOrchestratorComponent, resolveDeferredDeps: () => [import('./ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs').then(m => m.default)], resolveMetadata: PageFieldComponent => ({ decorators: [{
4147
4463
  type: Component,
4148
4464
  args: [{ selector: 'div[page-orchestrator]', imports: [PageFieldComponent], template: `
4149
4465
  @for (pageField of pageFields(); track pageField.key; let i = $index) {
@@ -4575,6 +4891,85 @@ function isValidNonPagedForm(fields) {
4575
4891
  return result.mode === 'non-paged' && result.isValid;
4576
4892
  }
4577
4893
 
4894
+ function createSlot() {
4895
+ let counter = 0;
4896
+ const templates = new Map();
4897
+ const itemOrder = [];
4898
+ const primitiveFieldKey = signal(undefined, ...(ngDevMode ? [{ debugName: "primitiveFieldKey" }] : /* istanbul ignore next */ []));
4899
+ return {
4900
+ templates,
4901
+ itemOrder,
4902
+ primitiveFieldKey,
4903
+ nextId: () => `item-${counter++}`,
4904
+ setTemplate(itemId, t) {
4905
+ templates.set(itemId, t);
4906
+ },
4907
+ getTemplate(itemId) {
4908
+ return templates.get(itemId);
4909
+ },
4910
+ hasTemplate(itemId) {
4911
+ return templates.has(itemId);
4912
+ },
4913
+ setIdAt(index, itemId) {
4914
+ itemOrder[index] = itemId;
4915
+ },
4916
+ removeAt(index) {
4917
+ const [removed] = itemOrder.splice(index, 1);
4918
+ if (removed !== undefined)
4919
+ templates.delete(removed);
4920
+ },
4921
+ moveItem(fromIndex, toIndex) {
4922
+ const [id] = itemOrder.splice(fromIndex, 1);
4923
+ if (id !== undefined)
4924
+ itemOrder.splice(toIndex, 0, id);
4925
+ },
4926
+ truncate(length) {
4927
+ const removed = itemOrder.splice(length);
4928
+ for (const id of removed)
4929
+ templates.delete(id);
4930
+ },
4931
+ clear() {
4932
+ templates.clear();
4933
+ itemOrder.length = 0;
4934
+ counter = 0;
4935
+ primitiveFieldKey.set(undefined);
4936
+ },
4937
+ };
4938
+ }
4939
+ /**
4940
+ * Form-scoped registry of per-array durable state. One slot per array path
4941
+ * (e.g. 'members', 'team.members', 'orders.lineItems'). Survives
4942
+ * `ArrayFieldComponent` lifecycle, dies with the `DynamicForm`. Cleared on
4943
+ * schema swap by the `formSetup` effect in `FormStateManager`.
4944
+ *
4945
+ * `ArrayFieldComponent` injects this service and obtains its slot via
4946
+ * `slotFor(arrayPath)` on mount. Replaces the prior per-component
4947
+ * `ARRAY_TEMPLATE_REGISTRY` + `ARRAY_ITEM_ID_GENERATOR` providers.
4948
+ */
4949
+ class ArrayItemRegistryService {
4950
+ slots = new Map();
4951
+ /** Get-or-create a slot for the array at the given path. */
4952
+ slotFor(arrayPath) {
4953
+ let slot = this.slots.get(arrayPath);
4954
+ if (!slot) {
4955
+ slot = createSlot();
4956
+ this.slots.set(arrayPath, slot);
4957
+ }
4958
+ return slot;
4959
+ }
4960
+ /** Drop all slots — called from the `formSetup` effect on schema change. */
4961
+ clearAll() {
4962
+ for (const slot of this.slots.values())
4963
+ slot.clear();
4964
+ this.slots.clear();
4965
+ }
4966
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
4967
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService });
4968
+ }
4969
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ArrayItemRegistryService, decorators: [{
4970
+ type: Injectable
4971
+ }] });
4972
+
4578
4973
  class SchemaRegistryService {
4579
4974
  registeredSchemas = new Map();
4580
4975
  /**
@@ -4610,10 +5005,10 @@ class SchemaRegistryService {
4610
5005
  }
4611
5006
  return schema;
4612
5007
  }
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 });
5008
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
5009
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService });
4615
5010
  }
4616
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: SchemaRegistryService, decorators: [{
5011
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: SchemaRegistryService, decorators: [{
4617
5012
  type: Injectable
4618
5013
  }] });
4619
5014
 
@@ -4624,7 +5019,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
4624
5019
  * Use this guard when you need type-safe access to `FunctionHttpValidatorConfig` properties.
4625
5020
  */
4626
5021
  function isFunctionHttpValidator(config) {
4627
- return 'functionName' in config && !!config.functionName;
5022
+ const c = config;
5023
+ return typeof c.functionName === 'string' || (c.fn !== null && typeof c.fn === 'object');
4628
5024
  }
4629
5025
 
4630
5026
  /**
@@ -4820,8 +5216,6 @@ class HttpConditionFunctionCacheService {
4820
5216
  * );
4821
5217
  * // user.value() keeps the old user data during loading transitions
4822
5218
  * ```
4823
- *
4824
- * @experimental Uses Angular's experimental resource composition APIs.
4825
5219
  */
4826
5220
  function withPreviousValue(input) {
4827
5221
  const derived = linkedSignal({ ...(ngDevMode ? { debugName: "derived" } : /* istanbul ignore next */ {}), source: input.snapshot,
@@ -5043,11 +5437,18 @@ function createAsyncConditionLogicFunction(condition) {
5043
5437
  const cacheService = inject(AsyncConditionFunctionCacheService);
5044
5438
  const pendingValue = condition.pendingValue ?? false;
5045
5439
  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;
5440
+ if (condition.asyncFn && condition.asyncFunctionName) {
5441
+ logger.warn('Both "asyncFn" and "asyncFunctionName" are set on async condition. Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.');
5442
+ }
5443
+ // Skip cross-condition caching when an inline function is used: stableStringify
5444
+ // cannot distinguish two different inline functions, which would cause cache hits
5445
+ // to wrongly reuse a LogicFn closing over a different function reference.
5446
+ const cacheKey = condition.asyncFn ? undefined : stableStringify(condition);
5447
+ if (cacheKey !== undefined) {
5448
+ const cached = cacheService.asyncConditionFunctionCache.get(cacheKey);
5449
+ if (cached) {
5450
+ return cached;
5451
+ }
5051
5452
  }
5052
5453
  // Each condition needs its own per-field signal store.
5053
5454
  const perFunctionSignalStore = new Map();
@@ -5072,8 +5473,10 @@ function createAsyncConditionLogicFunction(condition) {
5072
5473
  const asyncResource = rxResource({
5073
5474
  params: () => debouncedTrigger() ?? undefined,
5074
5475
  stream: ({ params: triggerKey }) => {
5075
- // Look up the async function at call time (fresh reference)
5076
- const asyncFn = functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName);
5476
+ // Inline `asyncFn` wins; otherwise look up the registered function at call time
5477
+ // (fresh reference, in case the registry was updated after LogicFn creation).
5478
+ const asyncFn = condition.asyncFn ??
5479
+ (condition.asyncFunctionName ? functionRegistry.getAsyncConditionFunction(condition.asyncFunctionName) : undefined);
5077
5480
  if (!asyncFn) {
5078
5481
  logger.warn(`Async Condition - function '${condition.asyncFunctionName}' not found. ` +
5079
5482
  `Register it in customFnConfig.asyncConditions.`);
@@ -5125,7 +5528,9 @@ function createAsyncConditionLogicFunction(condition) {
5125
5528
  });
5126
5529
  return resultResource.value();
5127
5530
  };
5128
- cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
5531
+ if (cacheKey !== undefined) {
5532
+ cacheService.asyncConditionFunctionCache.set(cacheKey, fn);
5533
+ }
5129
5534
  return fn;
5130
5535
  }
5131
5536
 
@@ -5760,12 +6165,12 @@ function applyCustomValidator(config, fieldPath) {
5760
6165
  if (config.expression) {
5761
6166
  validatorFn = createExpressionValidator(config);
5762
6167
  }
5763
- else if (config.functionName) {
6168
+ else if (config.fn || config.functionName) {
5764
6169
  validatorFn = createFunctionValidator(config);
5765
6170
  }
5766
6171
  else {
5767
6172
  const logger = inject(DynamicFormLogger);
5768
- logger.warn('Custom validator must have either "expression" or "functionName"');
6173
+ logger.warn('Custom validator must have one of "expression", "functionName", or "fn"');
5769
6174
  return;
5770
6175
  }
5771
6176
  const whenLogic = createConditionalLogic(config.when);
@@ -5777,6 +6182,13 @@ function applyCustomValidator(config, fieldPath) {
5777
6182
  }
5778
6183
  function createFunctionValidator(config) {
5779
6184
  const logger = inject(DynamicFormLogger);
6185
+ if (config.fn && config.functionName) {
6186
+ logger.warn('Both "fn" and "functionName" are set on custom validator. Inline "fn" takes precedence; "functionName" is ignored.');
6187
+ }
6188
+ if (config.fn) {
6189
+ const inlineFn = config.fn;
6190
+ return (ctx) => inlineFn(ctx, config.params);
6191
+ }
5780
6192
  const functionName = config.functionName;
5781
6193
  if (!functionName) {
5782
6194
  logger.warn('Custom validator missing functionName');
@@ -5837,10 +6249,24 @@ function createExpressionValidator(config) {
5837
6249
  * - onError: Optional handler for resource errors
5838
6250
  */
5839
6251
  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.`);
6252
+ if (config.fn && config.functionName) {
6253
+ const logger = inject(DynamicFormLogger);
6254
+ logger.warn('Both "fn" and "functionName" are set on async validator. Inline "fn" takes precedence; "functionName" is ignored.');
6255
+ }
6256
+ let validatorConfig;
6257
+ if (config.fn) {
6258
+ validatorConfig = config.fn;
6259
+ }
6260
+ else if (config.functionName) {
6261
+ const registry = inject(FunctionRegistryService);
6262
+ validatorConfig = registry.getAsyncValidator(config.functionName);
6263
+ if (!validatorConfig) {
6264
+ throw new DynamicFormError(`Async validator "${config.functionName}" not found. Register it with customFnConfig.asyncValidators.`);
6265
+ }
6266
+ }
6267
+ else {
6268
+ // XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
6269
+ throw new DynamicFormError('Async validator requires one of "functionName" or "fn".');
5844
6270
  }
5845
6271
  const whenLogic = createConditionalLogic(config.when);
5846
6272
  const asyncOptions = {
@@ -5862,10 +6288,16 @@ function applyAsyncValidator(config, fieldPath) {
5862
6288
  function applyUnifiedHttpValidator(config, fieldPath) {
5863
6289
  if (isFunctionHttpValidator(config)) {
5864
6290
  applyFunctionHttpValidator(config, fieldPath);
6291
+ return;
5865
6292
  }
5866
- else {
5867
- applyDeclarativeHttpValidator(config, fieldPath);
6293
+ // Declarative path requires both `http` and `responseMapping` — reject early
6294
+ // for JSON-loaded configs that have neither side fully populated. The
6295
+ // discriminated union forbids this at compile time.
6296
+ const declarative = config;
6297
+ if (!declarative.http || !declarative.responseMapping) {
6298
+ throw new DynamicFormError('HTTP validator requires one of "functionName" / "fn" (function-based) or "http" + "responseMapping" (declarative).');
5868
6299
  }
6300
+ applyDeclarativeHttpValidator(config, fieldPath);
5869
6301
  }
5870
6302
  /**
5871
6303
  * Apply function-based HTTP validator to field path using Angular's public validateHttp() API.
@@ -5876,10 +6308,24 @@ function applyUnifiedHttpValidator(config, fieldPath) {
5876
6308
  * - onError: Optional handler for HTTP errors
5877
6309
  */
5878
6310
  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.`);
6311
+ if (config.fn && config.functionName) {
6312
+ const logger = inject(DynamicFormLogger);
6313
+ logger.warn('Both "fn" and "functionName" are set on HTTP validator. Inline "fn" takes precedence; "functionName" is ignored.');
6314
+ }
6315
+ let httpValidatorConfig;
6316
+ if (config.fn) {
6317
+ httpValidatorConfig = config.fn;
6318
+ }
6319
+ else if (config.functionName) {
6320
+ const registry = inject(FunctionRegistryService);
6321
+ httpValidatorConfig = registry.getHttpValidator(config.functionName);
6322
+ if (!httpValidatorConfig) {
6323
+ throw new DynamicFormError(`HTTP validator "${config.functionName}" not found. Register it with customFnConfig.httpValidators.`);
6324
+ }
6325
+ }
6326
+ else {
6327
+ // XOR type rejects this at compile time; JSON-loaded configs can still reach here at runtime.
6328
+ throw new DynamicFormError('HTTP validator requires one of "functionName" or "fn".');
5883
6329
  }
5884
6330
  const whenLogic = createConditionalLogic(config.when);
5885
6331
  const httpOptions = {
@@ -8232,9 +8678,13 @@ function buildClassName(fieldDef) {
8232
8678
  * @returns Record of input names to values
8233
8679
  */
8234
8680
  function buildBaseInputs(fieldDef, defaultProps) {
8235
- const { key, label, className, tabIndex, props, meta } = fieldDef;
8681
+ const { key, label, className, tabIndex, props, meta, addons } = fieldDef;
8236
8682
  const inputs = {};
8237
- // Always include key - required by components for accessibility and identification
8683
+ // Always include key — required by components for accessibility and identification.
8684
+ // `type` is intentionally NOT propagated to the field component (it's the
8685
+ // registry discriminant, not a component input); wrappers and addons read it
8686
+ // from `WrapperFieldInputs.type` which is injected at the buildFieldInputs()
8687
+ // boundary by `DfFieldOutlet`.
8238
8688
  inputs['key'] = key;
8239
8689
  if (label !== undefined) {
8240
8690
  inputs['label'] = label;
@@ -8264,6 +8714,13 @@ function buildBaseInputs(fieldDef, defaultProps) {
8264
8714
  if (meta !== undefined) {
8265
8715
  inputs['meta'] = meta;
8266
8716
  }
8717
+ // Forward addons. Field components that don't declare an `addons` input
8718
+ // signal (Tier 3 — toggle, checkbox, radio, slider) ignore this key via
8719
+ // Angular's `setInput` no-op-on-unknown behaviour, so it's safe to forward
8720
+ // unconditionally.
8721
+ if (addons !== undefined) {
8722
+ inputs['addons'] = addons;
8723
+ }
8267
8724
  return inputs;
8268
8725
  }
8269
8726
  /**
@@ -8463,22 +8920,31 @@ const PLACEHOLDER_INDEX = '$';
8463
8920
  /**
8464
8921
  * Builds a property override store key for a field.
8465
8922
  *
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`).
8923
+ * For non-array fields, the key is the dot-joined group ancestors plus the
8924
+ * field's leaf key (e.g., `address.street` for `street` inside `address`).
8925
+ * For array item fields, the key includes the array path, either a concrete
8926
+ * index or the `$` placeholder, and any group ancestors INSIDE the array
8927
+ * item (e.g., `contacts.0.email`, `contacts.$.email`, or
8928
+ * `contacts.0.profile.email` for a group nested under each contact).
8929
+ *
8930
+ * `groupPath` matches the form-value path of nested groups (collector resets
8931
+ * it at array boundaries — see `property-derivation-collector.ts`), so the
8932
+ * resulting key always mirrors the field's location in `formValue`.
8469
8933
  *
8470
8934
  * @param arrayKey - The array field key (e.g., 'contacts'), or undefined for non-array fields
8471
8935
  * @param index - The array item index, `PLACEHOLDER_INDEX` for the wildcard format, or undefined for non-array fields
8472
8936
  * @param fieldKey - The leaf field key (e.g., 'email')
8473
- * @returns The store key (e.g., 'email', 'contacts.0.email', or 'contacts.$.email')
8937
+ * @param groupPath - Dot-joined group ancestors scoped to the current array item (or top-level), or undefined when not inside any group
8938
+ * @returns The store key (e.g., 'email', 'address.street', 'contacts.0.email', 'contacts.$.profile.email')
8474
8939
  *
8475
8940
  * @public
8476
8941
  */
8477
- function buildPropertyOverrideKey(arrayKey, index, fieldKey) {
8942
+ function buildPropertyOverrideKey(arrayKey, index, fieldKey, groupPath) {
8943
+ const leafScope = groupPath ? `${groupPath}.${fieldKey}` : fieldKey;
8478
8944
  if (arrayKey != null && index != null) {
8479
- return `${arrayKey}.${index}.${fieldKey}`;
8945
+ return `${arrayKey}.${index}.${leafScope}`;
8480
8946
  }
8481
- return fieldKey;
8947
+ return leafScope;
8482
8948
  }
8483
8949
 
8484
8950
  /**
@@ -8559,6 +9025,25 @@ function applyIndexSuffix(inputs, index) {
8559
9025
  key: `${key}_${index}`,
8560
9026
  };
8561
9027
  }
9028
+ /**
9029
+ * Prefixes the key with the underscored group ancestor path so that the same
9030
+ * leaf key inside different groups produces distinct DOM IDs (issue #401).
9031
+ * Form-schema keys remain clean — only the rendered `key` input is rewritten.
9032
+ *
9033
+ * Dots in the group path become underscores, then the original leaf key is
9034
+ * appended: `(groupPath='user.address', inputs.key='street')` →
9035
+ * `inputs.key = 'user_address_street'`.
9036
+ */
9037
+ function applyGroupPrefix(inputs, groupPath) {
9038
+ const key = inputs['key'];
9039
+ if (typeof key !== 'string' || groupPath.length === 0) {
9040
+ return inputs;
9041
+ }
9042
+ return {
9043
+ ...inputs,
9044
+ key: `${groupPath.replace(/\./g, '_')}_${key}`,
9045
+ };
9046
+ }
8562
9047
  /**
8563
9048
  * Main field mapper function that uses the field registry to get the appropriate mapper
8564
9049
  * based on the field's type property.
@@ -8595,6 +9080,10 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8595
9080
  // Check if we're inside an array item context - if so, we need to suffix keys
8596
9081
  // Optional because ARRAY_CONTEXT is only provided inside array item injectors
8597
9082
  const arrayContext = inject(ARRAY_CONTEXT, { optional: true });
9083
+ // Check if we're inside a group context - if so, prefix the key with the group path
9084
+ // so overlapping leaf keys across groups produce distinct DOM IDs (issue #401).
9085
+ // Optional because GROUP_CONTEXT is only provided by GroupFieldComponent.
9086
+ const groupContext = inject(GROUP_CONTEXT, { optional: true });
8598
9087
  // Inject the property override store for property derivation overrides
8599
9088
  // Always available — provided at the DynamicForm component level via provideDynamicFormDI
8600
9089
  const store = inject(PROPERTY_OVERRIDE_STORE);
@@ -8605,6 +9094,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8605
9094
  // Check that arrayContext exists and has a valid index signal (guards against mock injectors)
8606
9095
  const indexSignal = arrayContext?.index;
8607
9096
  const hasArrayContext = indexSignal !== undefined && isSignal(indexSignal);
9097
+ const hasGroupContext = groupContext != null;
8608
9098
  // Fast-path check for property overrides: only fields whose definition declares
8609
9099
  // a property-derivation enter the computed() wrapper. We inspect the FieldDef
8610
9100
  // directly rather than asking the store, because the store's registration
@@ -8613,7 +9103,7 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8613
9103
  // collector apply the same inclusion rule, so the answers always agree.
8614
9104
  const hasOverrides = fieldHasPropertyDerivations(fieldDef);
8615
9105
  // Fast path: no transformations needed
8616
- if (!hasPropsForwarding && !hasArrayContext && !hasOverrides) {
9106
+ if (!hasPropsForwarding && !hasArrayContext && !hasGroupContext && !hasOverrides) {
8617
9107
  return mapperResult;
8618
9108
  }
8619
9109
  // Wrap in computed to apply transformations
@@ -8623,18 +9113,34 @@ function mapFieldToInputs(fieldDef, fieldRegistry) {
8623
9113
  if (hasPropsForwarding) {
8624
9114
  inputs = mergeForwardedPropsToMeta(inputs, propsToMeta);
8625
9115
  }
9116
+ // Apply group prefix BEFORE array suffix so the resulting key reads as
9117
+ // `{groupPath}_{key}_{index}` (e.g. `address_street_0`) — matching the
9118
+ // form-value path order: group → leaf → array index.
9119
+ if (hasGroupContext) {
9120
+ // Non-null after the hasGroupContext guard.
9121
+ inputs = applyGroupPrefix(inputs, groupContext.groupPath());
9122
+ }
8626
9123
  // Apply index suffix for array items
8627
9124
  if (hasArrayContext) {
8628
9125
  const index = indexSignal();
8629
9126
  inputs = applyIndexSuffix(inputs, index);
8630
9127
  }
8631
- // Apply property overrides from the store (AFTER all static transformations)
9128
+ // Apply property overrides from the store (AFTER all static transformations).
9129
+ //
9130
+ // INVARIANT: the store key MUST be built from `fieldDef.key` + the group/array
9131
+ // ancestry (via buildPropertyOverrideKey) — NEVER from the DOM-scoped
9132
+ // `inputs['key']` produced by applyGroupPrefix/applyIndexSuffix above. The
9133
+ // collector (property-derivation-collector.ts) registers using the same
9134
+ // shape; if these drift, overrides silently miss their target.
8632
9135
  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
9136
+ // Build inside computed() so the index/group signals establish reactive deps.
9137
+ // GROUP_CONTEXT resets at array boundaries (see create-array-item-injector.ts),
9138
+ // so groupPath() represents ancestors inside the current array item (or
9139
+ // top-level groups when not in an array) — matching the collector.
9140
+ const groupPath = hasGroupContext ? groupContext.groupPath() : undefined;
8635
9141
  const key = hasArrayContext
8636
- ? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key)
8637
- : fieldDef.key;
9142
+ ? buildPropertyOverrideKey(arrayContext.arrayKey, indexSignal(), fieldDef.key, groupPath || undefined)
9143
+ : buildPropertyOverrideKey(undefined, undefined, fieldDef.key, groupPath || undefined);
8638
9144
  const overrides = store.getOverrides(key)();
8639
9145
  inputs = applyPropertyOverrides(inputs, overrides);
8640
9146
  }
@@ -8653,6 +9159,40 @@ function createRenderReadySignal(inputs, definition) {
8653
9159
  return requiredInputs.every((inputName) => currentInputs[inputName] !== undefined);
8654
9160
  });
8655
9161
  }
9162
+ /**
9163
+ * Reads the current hidden state from mapper inputs. Uniform across all field
9164
+ * types — value-bearing leaves source from the `FieldTree`'s `state.hidden()`
9165
+ * (where Angular Signal Forms cascades `hidden(path, …)`), and non-field outputs
9166
+ * (button/text/containers) source from the mapper's explicit `inputs.hidden`.
9167
+ *
9168
+ * The `@if (!field.hidden())` gate at every `*dfFieldOutlet` consumption site uses
9169
+ * this signal, removing hidden fields from the DOM. That's required to silence
9170
+ * Angular's `NG01916: Field 'X' is hidden but is being rendered` warning — and
9171
+ * is the prescribed pattern from the
9172
+ * [`hidden` API docs](https://angular.dev/api/forms/signals/hidden).
9173
+ *
9174
+ * Container internal state (e.g. an `ArrayFieldComponent`'s items + dynamic
9175
+ * templates + id counter) survives this destroy/recreate via the form-scoped
9176
+ * `ArrayItemRegistryService` keyed by array path.
9177
+ */
9178
+ function createHiddenSignal(inputs) {
9179
+ return computed(() => {
9180
+ const currentInputs = inputs();
9181
+ const fieldCandidate = currentInputs['field'];
9182
+ // Value-bearing leaves expose `field` as a callable `FieldTree` whose invocation returns a
9183
+ // `FieldState` with a `hidden` accessor. `isSignal()` doesn't recognise the Proxy-wrapped
9184
+ // FieldTree (no SIGNAL symbol), so we duck-type the shape and call `state.hidden()` directly.
9185
+ if (typeof fieldCandidate === 'function') {
9186
+ const state = fieldCandidate();
9187
+ if (state && typeof state.hidden === 'function') {
9188
+ return state.hidden() === true;
9189
+ }
9190
+ }
9191
+ // Non-value fields (button, text, containers) carry an explicit `hidden` input set by
9192
+ // `applyHiddenLogic` based on the mapper-side evaluation.
9193
+ return currentInputs['hidden'] === true;
9194
+ });
9195
+ }
8656
9196
  /**
8657
9197
  * Resolves a single field definition to a ResolvedField using RxJS.
8658
9198
  * Loads the component asynchronously and maps inputs in the injection context.
@@ -8689,6 +9229,7 @@ function resolveField(fieldDef, context) {
8689
9229
  injector: context.injector,
8690
9230
  inputs,
8691
9231
  renderReady: createRenderReadySignal(inputs, definition),
9232
+ hidden: createHiddenSignal(inputs),
8692
9233
  };
8693
9234
  }), catchError((error) => {
8694
9235
  // Only call onError if component is not destroyed to avoid accessing cleaned-up state
@@ -8725,6 +9266,7 @@ function resolveFieldSync(fieldDef, context) {
8725
9266
  injector: context.injector,
8726
9267
  inputs,
8727
9268
  renderReady: createRenderReadySignal(inputs, definition),
9269
+ hidden: createHiddenSignal(inputs),
8728
9270
  };
8729
9271
  }
8730
9272
  /**
@@ -8751,13 +9293,14 @@ function reconcileFields(prev, curr) {
8751
9293
  }
8752
9294
  // Same component instance + injector, but the field definition changed.
8753
9295
  // 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.
9296
+ // while swapping in the freshly-mapped inputs/renderReady/hidden signals
9297
+ // so prop updates (e.g. select options) reach the component.
8756
9298
  return {
8757
9299
  ...existing,
8758
9300
  fieldDef: field.fieldDef,
8759
9301
  inputs: field.inputs,
8760
9302
  renderReady: field.renderReady,
9303
+ hidden: field.hidden,
8761
9304
  };
8762
9305
  }
8763
9306
  // New field, type changed, or context changed (new injector) - use new field
@@ -9075,15 +9618,32 @@ function injectFieldRegistry() {
9075
9618
  * Collects all field keys, types, and validates regex patterns from a field tree
9076
9619
  * by recursively traversing containers (page, row, group, array).
9077
9620
  *
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.
9621
+ * Keys are scoped by their group ancestors (e.g. `address.city` instead of `city`),
9622
+ * so the same leaf key can appear inside different groups without conflict. Page and
9623
+ * row containers do not contribute to the path because they flatten their children
9624
+ * into the parent scope. Array items are excluded entirely from key collection
9625
+ * because they share template keys across items by design.
9626
+ *
9627
+ * Fields whose registered `valueHandling` is anything other than `include` (e.g.
9628
+ * buttons with `exclude`, page/row with `flatten`) are not collected: they don't
9629
+ * bind to a form-value path, so duplicate-key uniqueness isn't meaningful for them.
9630
+ *
9631
+ * @param collectKeys - When false, the entire subtree skips key collection (used
9632
+ * when descending into an array container).
9082
9633
  */
9083
- function collectFieldData(fields, data, collectKeys = true) {
9634
+ function collectFieldData(fields, data, registry, pathPrefix, collectKeys = true) {
9084
9635
  for (const field of fields) {
9085
- if (collectKeys && field.key) {
9086
- data.keys.push(field.key);
9636
+ // Defensively skip only known non-include modes — unexpected/missing
9637
+ // valueHandling defaults back to participating in duplicate detection so a
9638
+ // mis-registered field type doesn't silently drop from collision checks.
9639
+ const valueHandling = field.type ? getFieldValueHandling(field.type, registry) : 'include';
9640
+ const participatesInValue = valueHandling !== 'exclude' && valueHandling !== 'flatten';
9641
+ if (collectKeys && field.key && participatesInValue) {
9642
+ const scopedKey = pathPrefix ? `${pathPrefix}.${field.key}` : field.key;
9643
+ data.keys.push(scopedKey);
9644
+ // Project to the DOM-ID format (dots → underscores) so we can detect
9645
+ // collisions like top-level `'foo_bar'` vs group `'foo'` + leaf `'bar'`.
9646
+ data.domIds.push(scopedKey.replace(/\./g, '_'));
9087
9647
  }
9088
9648
  if (field.type) {
9089
9649
  data.types.add(field.type);
@@ -9103,13 +9663,16 @@ function collectFieldData(fields, data, collectKeys = true) {
9103
9663
  // Array item fields share template keys across items by design — stop collecting keys
9104
9664
  // once inside an array. Types and regex patterns are still validated.
9105
9665
  const childCollectKeys = collectKeys && field.type !== 'array';
9666
+ // Only group containers extend the scoping path — page/row flatten into the parent
9667
+ // scope, and array items use dynamic indices that aren't statically knowable.
9668
+ const childPrefix = isGroupField(field) && field.key ? (pathPrefix ? `${pathPrefix}.${field.key}` : field.key) : pathPrefix;
9106
9669
  const children = normalizeFieldsArray(field.fields);
9107
9670
  for (const child of children) {
9108
9671
  if (Array.isArray(child)) {
9109
- collectFieldData(child, data, childCollectKeys);
9672
+ collectFieldData(child, data, registry, childPrefix, childCollectKeys);
9110
9673
  }
9111
9674
  else {
9112
- collectFieldData([child], data, childCollectKeys);
9675
+ collectFieldData([child], data, registry, childPrefix, childCollectKeys);
9113
9676
  }
9114
9677
  }
9115
9678
  }
@@ -9145,7 +9708,38 @@ function validateNoDuplicateKeys(allKeys) {
9145
9708
  const duplicateList = Array.from(duplicates)
9146
9709
  .map((k) => `'${k}'`)
9147
9710
  .join(', ');
9148
- throw new DynamicFormError(`Duplicate field keys detected: ${duplicateList}. Each field key must be unique within a form config.`);
9711
+ 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).`);
9712
+ }
9713
+ }
9714
+ /**
9715
+ * Validates that the underscore-projected DOM-ID namespace has no collisions.
9716
+ *
9717
+ * DOM IDs are built by underscore-joining the group ancestor path with the
9718
+ * leaf key (e.g. `address_street`). Two scoped paths can land on the same
9719
+ * DOM ID even when their form-value paths differ — e.g. a top-level key
9720
+ * `'foo_bar'` and a leaf `'bar'` inside group `'foo'` both render as
9721
+ * `id="foo_bar"`. The form-value-path check accepts this (paths are
9722
+ * `'foo_bar'` vs `'foo.bar'` — distinct) but the rendered DOM would have
9723
+ * duplicate IDs, breaking aria associations and CSS selectors.
9724
+ *
9725
+ * @throws {DynamicFormError} When two scoped paths project to the same DOM ID
9726
+ */
9727
+ function validateNoDomIdCollisions(domIds) {
9728
+ const seen = new Set();
9729
+ const duplicates = new Set();
9730
+ for (const id of domIds) {
9731
+ if (seen.has(id)) {
9732
+ duplicates.add(id);
9733
+ }
9734
+ seen.add(id);
9735
+ }
9736
+ if (duplicates.size > 0) {
9737
+ const duplicateList = Array.from(duplicates)
9738
+ .map((k) => `'${k}'`)
9739
+ .join(', ');
9740
+ throw new DynamicFormError(`DOM id collision detected for: ${duplicateList}. Group-nested keys are joined with '_' to form DOM ids ` +
9741
+ `(e.g. group 'foo' + leaf 'bar' renders as id='foo_bar'). A separate top-level key like 'foo_bar' or a different ` +
9742
+ `group/leaf pair that underscores to the same string will produce duplicate ids. Rename one of the colliding keys.`);
9149
9743
  }
9150
9744
  }
9151
9745
  /**
@@ -9183,11 +9777,13 @@ function validateFieldTypesRegistered(allTypes, registry, logger) {
9183
9777
  function validateFormConfig(fields, registry, logger) {
9184
9778
  const data = {
9185
9779
  keys: [],
9780
+ domIds: [],
9186
9781
  types: new Set(),
9187
9782
  regexErrors: [],
9188
9783
  };
9189
- collectFieldData(fields, data);
9784
+ collectFieldData(fields, data, registry, '');
9190
9785
  validateNoDuplicateKeys(data.keys);
9786
+ validateNoDomIdCollisions(data.domIds);
9191
9787
  validateFieldTypesRegistered(data.types, registry, logger);
9192
9788
  if (data.regexErrors.length > 0) {
9193
9789
  throw new DynamicFormError(data.regexErrors.length === 1
@@ -9196,6 +9792,272 @@ function validateFormConfig(fields, registry, logger) {
9196
9792
  }
9197
9793
  }
9198
9794
 
9795
+ /**
9796
+ * Walk a field's addons; return the survivors plus a list of warnings for
9797
+ * anything dropped.
9798
+ *
9799
+ * Pure: receives the registries as arguments (so the function is reusable
9800
+ * outside Angular's DI — tests, server-side validation, etc.).
9801
+ *
9802
+ * @param field The field to validate.
9803
+ * @param fieldRegistry Snapshot of `FIELD_REGISTRY` map.
9804
+ * @param kindRegistry Snapshot of `ADDON_KIND_REGISTRY` map.
9805
+ * @param source Whether the config originated from JSON (lenient drop
9806
+ * of code-only kinds) or inline TypeScript.
9807
+ */
9808
+ function validateFieldAddons(field, fieldRegistry, kindRegistry, source) {
9809
+ const warnings = [];
9810
+ const incoming = field.addons;
9811
+ if (!incoming?.length) {
9812
+ return { addons: [], warnings };
9813
+ }
9814
+ const fieldType = field.type;
9815
+ const fieldDef = fieldRegistry.get(fieldType);
9816
+ if (!fieldDef) {
9817
+ warnings.push({ type: 'unknown-field-type', fieldKey: field.key, fieldType });
9818
+ return { addons: [], warnings };
9819
+ }
9820
+ const addonSupport = fieldDef.addons;
9821
+ if (!addonSupport) {
9822
+ warnings.push({ type: 'field-type-no-addon-support', fieldKey: field.key, fieldType });
9823
+ return { addons: [], warnings };
9824
+ }
9825
+ const allowedSlots = addonSupport.slots;
9826
+ const allowedKinds = addonSupport.allowedKinds;
9827
+ const registeredKinds = Array.from(kindRegistry.keys());
9828
+ const survivors = [];
9829
+ for (const addon of incoming) {
9830
+ // Slot allowed?
9831
+ if (!allowedSlots.includes(addon.slot)) {
9832
+ warnings.push({
9833
+ type: 'unknown-slot',
9834
+ fieldKey: field.key,
9835
+ fieldType,
9836
+ slot: String(addon.slot),
9837
+ allowedSlots,
9838
+ });
9839
+ continue;
9840
+ }
9841
+ // Kind registered? `AnyAddon.kind` is already a string-literal union via
9842
+ // DynamicFormAddonRegistry; no need to cast.
9843
+ const kindDef = kindRegistry.get(addon.kind);
9844
+ if (!kindDef) {
9845
+ warnings.push({
9846
+ type: 'unknown-kind',
9847
+ fieldKey: field.key,
9848
+ kind: addon.kind,
9849
+ registeredKinds,
9850
+ });
9851
+ continue;
9852
+ }
9853
+ // Kind whitelisted (if `allowedKinds` was set)?
9854
+ if (allowedKinds && !allowedKinds.includes(kindDef.kind)) {
9855
+ warnings.push({
9856
+ type: 'kind-not-allowed',
9857
+ fieldKey: field.key,
9858
+ fieldType,
9859
+ kind: kindDef.kind,
9860
+ allowedKinds,
9861
+ });
9862
+ continue;
9863
+ }
9864
+ // Code-only kind dropped from JSON-source configs. A kind opts out of
9865
+ // JSON-safety via `AddonKindDefinition.jsonSafe: false` (defaults to true).
9866
+ if (source === 'json' && kindDef.jsonSafe === false) {
9867
+ warnings.push({
9868
+ type: 'code-only-kind-in-json',
9869
+ fieldKey: field.key,
9870
+ kind: kindDef.kind,
9871
+ });
9872
+ continue;
9873
+ }
9874
+ // Multi-set XOR + JSON-source function stripping. Both runs operate on
9875
+ // a working copy via `ensureCopy()` so the original config isn't
9876
+ // mutated — the survivor pushed at the bottom is either the original
9877
+ // addon (no strips) or the sanitized copy.
9878
+ let sanitized = addon;
9879
+ let stripped = false;
9880
+ const ensureCopy = () => {
9881
+ if (!stripped)
9882
+ sanitized = { ...sanitized };
9883
+ stripped = true;
9884
+ };
9885
+ // Snapshot which click variants the original config declared BEFORE
9886
+ // any stripping. Computing variants AFTER function-strip would
9887
+ // underreport the multi-variant warning (`action` would silently
9888
+ // vanish from the reported list).
9889
+ const declaredVariants = ['preset', 'actionRef', 'action'].filter((k) => sanitized[k] !== undefined);
9890
+ // JSON-source function strip — inline `action`/`hidden`/`disabled`/
9891
+ // `loading` functions can't round-trip through serialization, so they
9892
+ // get dropped here with a warning. Inline-source configs keep them.
9893
+ if (source === 'json') {
9894
+ const dropReactiveFn = (key) => {
9895
+ if (typeof sanitized[key] === 'function') {
9896
+ ensureCopy();
9897
+ delete sanitized[key];
9898
+ warnings.push({
9899
+ type: 'code-only-action-in-json',
9900
+ fieldKey: field.key,
9901
+ reason: `function '${key}' on kind '${kindDef.kind}' (use a signal/observable in code, or omit in JSON)`,
9902
+ });
9903
+ }
9904
+ };
9905
+ if (typeof sanitized.action === 'function') {
9906
+ ensureCopy();
9907
+ delete sanitized.action;
9908
+ warnings.push({
9909
+ type: 'code-only-action-in-json',
9910
+ fieldKey: field.key,
9911
+ reason: `inline 'action' function on kind '${kindDef.kind}'`,
9912
+ });
9913
+ }
9914
+ dropReactiveFn('hidden');
9915
+ dropReactiveFn('disabled');
9916
+ dropReactiveFn('loading');
9917
+ }
9918
+ // Multi-set XOR — applies to BOTH inline and JSON sources. TypeScript
9919
+ // discriminated unions already reject multi-set at compile time, but
9920
+ // configs that bypass type-checking (loose `any`, runtime composition,
9921
+ // JSON parsing) still need defence-in-depth. Keep the first variant,
9922
+ // strip the rest; mirrors the runtime precedence
9923
+ // (preset > actionRef > action) so the chosen handler matches what
9924
+ // the dispatcher would have picked.
9925
+ if (declaredVariants.length > 1) {
9926
+ ensureCopy();
9927
+ for (const k of declaredVariants.slice(1))
9928
+ delete sanitized[k];
9929
+ warnings.push({
9930
+ type: 'code-only-action-in-json',
9931
+ fieldKey: field.key,
9932
+ reason: `multiple click variants on kind '${kindDef.kind}' (${declaredVariants.join(', ')}); kept '${declaredVariants[0]}'`,
9933
+ });
9934
+ }
9935
+ if (stripped) {
9936
+ survivors.push(sanitized);
9937
+ continue;
9938
+ }
9939
+ // Per-kind shape validator (throws on violation).
9940
+ if (kindDef.validate) {
9941
+ try {
9942
+ kindDef.validate(addon, field.key);
9943
+ }
9944
+ catch (error) {
9945
+ warnings.push({
9946
+ type: 'shape-violation',
9947
+ fieldKey: field.key,
9948
+ kind: kindDef.kind,
9949
+ reason: error instanceof Error ? error.message : String(error),
9950
+ });
9951
+ continue;
9952
+ }
9953
+ }
9954
+ survivors.push(addon);
9955
+ }
9956
+ return { addons: survivors, warnings };
9957
+ }
9958
+ /**
9959
+ * Recursively walk a field tree, validating each field's addons. Returns a
9960
+ * sanitized copy of the input field array plus accumulated warnings.
9961
+ */
9962
+ function walkAndValidateAddons(fields, fieldRegistry, kindRegistry, source) {
9963
+ const allWarnings = [];
9964
+ const sanitized = fields.map((field) => {
9965
+ let next = field;
9966
+ if (field.addons?.length) {
9967
+ const { addons, warnings } = validateFieldAddons(field, fieldRegistry, kindRegistry, source);
9968
+ allWarnings.push(...warnings);
9969
+ next = { ...next, addons };
9970
+ }
9971
+ // Recurse into containers — every container field type carries its
9972
+ // children on a `fields` array.
9973
+ const children = next.fields;
9974
+ if (Array.isArray(children)) {
9975
+ const { fields: visited, warnings } = walkAndValidateAddons(children, fieldRegistry, kindRegistry, source);
9976
+ allWarnings.push(...warnings);
9977
+ next = { ...next, fields: visited };
9978
+ }
9979
+ return next;
9980
+ });
9981
+ return { fields: sanitized, warnings: allWarnings };
9982
+ }
9983
+
9984
+ /**
9985
+ * Stable structural fingerprint for an `AddonWarning`, used by the
9986
+ * FormStateManager dedup cache. Includes every distinguishing field
9987
+ * (`type`, `fieldKey`, `kind`/`slot`/`reason` as relevant) so warnings
9988
+ * for different fields or different kinds never collapse to one log
9989
+ * line — independent of the human-readable format which may change.
9990
+ */
9991
+ function addonWarningKey(w) {
9992
+ switch (w.type) {
9993
+ case 'unknown-field-type':
9994
+ return `${w.type}|${w.fieldKey}|${w.fieldType}`;
9995
+ case 'field-type-no-addon-support':
9996
+ return `${w.type}|${w.fieldKey}|${w.fieldType}`;
9997
+ case 'unknown-slot':
9998
+ return `${w.type}|${w.fieldKey}|${w.fieldType}|${w.slot}`;
9999
+ case 'unknown-kind':
10000
+ return `${w.type}|${w.fieldKey}|${w.kind}`;
10001
+ case 'kind-not-allowed':
10002
+ return `${w.type}|${w.fieldKey}|${w.fieldType}|${w.kind}`;
10003
+ case 'shape-violation':
10004
+ return `${w.type}|${w.fieldKey}|${w.kind}|${w.reason}`;
10005
+ case 'code-only-kind-in-json':
10006
+ return `${w.type}|${w.fieldKey}|${w.kind}`;
10007
+ case 'code-only-action-in-json':
10008
+ return `${w.type}|${w.fieldKey}|${w.reason}`;
10009
+ }
10010
+ }
10011
+ /**
10012
+ * Render an `AddonWarning` to a developer-friendly message.
10013
+ *
10014
+ * Returns the bare message — the `[Dynamic Forms]` prefix is added by the
10015
+ * library's logger (`ConsoleLogger`) and by `DynamicFormError`. Pass the
10016
+ * result of this function directly into `logger.warn(...)` to get the
10017
+ * standard prefixed line; use `logAddonWarnings()` for direct
10018
+ * `console.warn` output (which prefixes manually).
10019
+ *
10020
+ * Messages are designed to be actionable: they enumerate the relevant set
10021
+ * (registered kinds, allowed slots) and suggest a likely fix.
10022
+ */
10023
+ function formatAddonWarning(w) {
10024
+ switch (w.type) {
10025
+ case 'unknown-field-type':
10026
+ return `Field '${w.fieldKey}': unknown field type '${w.fieldType}'. Addons dropped.`;
10027
+ case 'field-type-no-addon-support':
10028
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): this field type does not declare addon support. Addons dropped. ` +
10029
+ `If this is incorrect, add 'addons: { slots: [...] }' to the field type registration.`);
10030
+ case 'unknown-slot':
10031
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): slot '${w.slot}' is not supported. ` +
10032
+ `Allowed: ${w.allowedSlots.join(', ')}. Addon dropped.`);
10033
+ case 'unknown-kind':
10034
+ return (`Field '${w.fieldKey}': addon kind '${w.kind}' is not registered. ` +
10035
+ `Did you forget withCustomAddon({ kind: '${w.kind}', ... })? ` +
10036
+ `Currently registered: ${w.registeredKinds.join(', ') || '(none)'}. Addon dropped.`);
10037
+ case 'kind-not-allowed':
10038
+ return (`Field '${w.fieldKey}' (type: '${w.fieldType}'): addon kind '${w.kind}' is not in the allowed list. ` +
10039
+ `Allowed: ${w.allowedKinds.join(', ')}. Addon dropped.`);
10040
+ case 'shape-violation':
10041
+ return `Field '${w.fieldKey}': addon kind '${w.kind}' shape invalid: ${w.reason}. Addon dropped.`;
10042
+ case 'code-only-kind-in-json':
10043
+ return `Field '${w.fieldKey}': addon kind '${w.kind}' is code-only and cannot be used with JSON-derived configs. Addon dropped.`;
10044
+ case 'code-only-action-in-json':
10045
+ return `Field '${w.fieldKey}': ${w.reason}. Use 'preset' or 'actionRef' instead. Addon dropped.`;
10046
+ }
10047
+ }
10048
+
10049
+ /**
10050
+ * Global registry of addon kinds.
10051
+ *
10052
+ * Populated by `withCustomAddon(...)` features and adapter feature helpers
10053
+ * (e.g., `withPrimeNGAddons()`). Read by `<df-addon-slot>` to resolve a
10054
+ * kind discriminant to its renderer component.
10055
+ */
10056
+ const ADDON_KIND_REGISTRY = new InjectionToken('ADDON_KIND_REGISTRY', {
10057
+ providedIn: 'root',
10058
+ factory: () => new Map(),
10059
+ });
10060
+
9199
10061
  /**
9200
10062
  * Injection token for global value exclusion defaults.
9201
10063
  *
@@ -9889,6 +10751,12 @@ class FormStateManager {
9889
10751
  eventBus = inject(EventBus);
9890
10752
  functionRegistry = inject(FunctionRegistryService);
9891
10753
  schemaRegistry = inject(SchemaRegistryService);
10754
+ // Optional so unit-test setups that drive FormStateManager directly without provideDynamicFormDI()
10755
+ // (e.g. mocked RootFormRegistryService specs) don't have to manually wire the registry. Real
10756
+ // DynamicForm usage always provides it via coreProviders; if you hit the dev warning below in
10757
+ // your own setup, you're using DynamicForm without provideDynamicFormDI() — fix the providers
10758
+ // chain rather than ignoring it.
10759
+ arrayItemRegistry = inject(ArrayItemRegistryService, { optional: true });
9892
10760
  /** Host component dependencies (config, formOptions, value). */
9893
10761
  deps = (() => {
9894
10762
  const raw = inject(FORM_STATE_DEPS);
@@ -9897,12 +10765,28 @@ class FormStateManager {
9897
10765
  'Ensure DynamicForm assigns its input signals to FORM_STATE_DEPS.');
9898
10766
  }
9899
10767
  // Safe cast: DynamicForm populates FormStateDeps with its concrete signals.
10768
+ // `source` is optional — `DynamicForm` always populates it, but other
10769
+ // callers of `FormStateManager` (e.g., specs) may omit it.
9900
10770
  return raw;
9901
10771
  })();
9902
10772
  /** Global value exclusion defaults. */
9903
10773
  valueExclusionDefaults = inject(VALUE_EXCLUSION_DEFAULTS);
9904
10774
  /** Field registry for loading components. */
9905
10775
  fieldRegistry = injectFieldRegistry();
10776
+ /** Addon-kind registry — feeds the addon validator at config-setup time. */
10777
+ addonKindRegistry = inject(ADDON_KIND_REGISTRY);
10778
+ /**
10779
+ * Fingerprints of addon warnings emitted by the last config setup.
10780
+ * Used to skip re-logging warnings that already fired for the previous
10781
+ * config — avoids spam when the form swaps config repeatedly but the
10782
+ * addon issues haven't changed.
10783
+ *
10784
+ * Scoping: per-FormStateManager (i.e., per `<df-dynamic-form>` component
10785
+ * instance). Replaced wholesale on every config setup — bounded by the
10786
+ * current config's warning count, never grows unbounded across swaps.
10787
+ * A warning that disappears and later returns re-logs as expected.
10788
+ */
10789
+ lastAddonWarningKeys = new Set();
9906
10790
  // ─────────────────────────────────────────────────────────────────────────────
9907
10791
  // State Machine
9908
10792
  // ─────────────────────────────────────────────────────────────────────────────
@@ -10515,12 +11399,24 @@ class FormStateManager {
10515
11399
  }
10516
11400
  });
10517
11401
  // Schema change resets the saved store + transition tracker so values from a stale schema
10518
- // can't leak into the new one.
11402
+ // can't leak into the new one. Also drops every per-array slot in the registry so a fresh
11403
+ // schema starts with an empty template/itemOrder/id-counter state.
11404
+ let warnedAboutMissingArrayRegistry = false;
10519
11405
  explicitEffect([this.formSetup], () => {
10520
11406
  this.prevFieldStateSnapshot.set({});
10521
11407
  if (Object.keys(this.excludedValueStore()).length > 0) {
10522
11408
  this.excludedValueStore.set({});
10523
11409
  }
11410
+ if (this.arrayItemRegistry) {
11411
+ this.arrayItemRegistry.clearAll();
11412
+ }
11413
+ else if (DEV_MODE && !warnedAboutMissingArrayRegistry) {
11414
+ warnedAboutMissingArrayRegistry = true;
11415
+ this.logger.warn('ArrayItemRegistryService was not provided to FormStateManager. ' +
11416
+ 'Array fields will lose dynamically-added item templates across hide/show cycles. ' +
11417
+ 'This usually means DynamicForm was bootstrapped without provideDynamicFormDI() — ' +
11418
+ 'check your providers chain.');
11419
+ }
10524
11420
  });
10525
11421
  // Outward sync uses boundFormValue (honors only explicit excludeValueIf* opt-ins) so
10526
11422
  // global defaults that target submission output don't reshape the host's bound model.
@@ -10578,11 +11474,35 @@ class FormStateManager {
10578
11474
  }
10579
11475
  createFormSetupFromConfig(fields, mode, registry) {
10580
11476
  const normalizedFields = normalizeSimplifiedArrays(fields);
10581
- // Validate after normalization so simplified array templates are already expanded
10582
- // into full ArrayField.fields and are reachable during traversal.
11477
+ // Two validation passes run after normalization so simplified array templates
11478
+ // are already expanded into full ArrayField.fields and reachable during traversal:
11479
+ // 1. Structural — duplicate keys, unknown field types, invalid regex patterns.
11480
+ // 2. Addon pass below — strips invalid/unsupported addons with lenient warnings.
10583
11481
  validateFormConfig(normalizedFields, registry, this.logger);
10584
- const flattenedFields = this.fieldProcessors.memoizedFlattenFields(normalizedFields, registry);
10585
- const flattenedFieldsForRendering = this.fieldProcessors.memoizedFlattenFieldsForRendering(normalizedFields, registry);
11482
+ // Addon pass — strip invalid / unsupported addon entries and log a warning
11483
+ // for each one dropped. Lenient by design: the form keeps rendering even
11484
+ // when a JSON-source config ships an addon the FE doesn't understand.
11485
+ //
11486
+ // Warnings are deduped across consecutive config setups: a warning that
11487
+ // already fired for the previous config is skipped, so swapping config
11488
+ // back and forth doesn't spam the console.
11489
+ const source = this.deps.source?.() ?? 'inline';
11490
+ const { fields: sanitizedFields, warnings: addonWarnings } = walkAndValidateAddons(normalizedFields, registry, this.addonKindRegistry, source);
11491
+ const nextKeys = new Set();
11492
+ for (const w of addonWarnings) {
11493
+ // Dedup uses a structural fingerprint so different fields with the
11494
+ // same warning category never collide; the rendered message is what
11495
+ // we log.
11496
+ const key = addonWarningKey(w);
11497
+ nextKeys.add(key);
11498
+ if (!this.lastAddonWarningKeys.has(key)) {
11499
+ this.logger.warn(formatAddonWarning(w));
11500
+ }
11501
+ }
11502
+ this.lastAddonWarningKeys = nextKeys;
11503
+ const validatedFields = sanitizedFields;
11504
+ const flattenedFields = this.fieldProcessors.memoizedFlattenFields(validatedFields, registry);
11505
+ const flattenedFieldsForRendering = this.fieldProcessors.memoizedFlattenFieldsForRendering(validatedFields, registry);
10586
11506
  const fieldsById = this.fieldProcessors.memoizedKeyBy(flattenedFields);
10587
11507
  const defaultValues = this.fieldProcessors.memoizedDefaultValues(fieldsById, registry);
10588
11508
  const fieldsToRender = mode === 'paged' ? [] : flattenedFieldsForRendering;
@@ -10610,10 +11530,10 @@ class FormStateManager {
10610
11530
  ngOnDestroy() {
10611
11531
  this.machine.dispatch({ type: Action.Destroy });
10612
11532
  }
10613
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
10614
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager });
11533
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
11534
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager });
10615
11535
  }
10616
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: FormStateManager, decorators: [{
11536
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: FormStateManager, decorators: [{
10617
11537
  type: Injectable
10618
11538
  }], ctorParameters: () => [] });
10619
11539
 
@@ -11032,15 +11952,18 @@ function computeValueFromEntry(entry, evalContext, options) {
11032
11952
  if (entry.expression) {
11033
11953
  return ExpressionParser.evaluate(entry.expression, evalContext);
11034
11954
  }
11035
- if (entry.functionName) {
11036
- const fn = options.derivationFunctions?.[entry.functionName];
11955
+ if (entry.fn || entry.functionName) {
11956
+ if (entry.fn && entry.functionName) {
11957
+ evalContext.logger.warn(`Both "fn" and "functionName" are set on derivation for '${options.subject}'. Inline "fn" takes precedence; "functionName" is ignored.`);
11958
+ }
11959
+ const fn = entry.fn ?? (entry.functionName ? options.derivationFunctions?.[entry.functionName] : undefined);
11037
11960
  if (!fn) {
11038
11961
  const kind = options.functionKind ?? 'Derivation function';
11039
11962
  throw new DynamicFormError(`${kind} '${entry.functionName}' not found in customFnConfig.derivations`);
11040
11963
  }
11041
11964
  return fn(evalContext);
11042
11965
  }
11043
- throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', or 'functionName'.`);
11966
+ throw new DynamicFormError(`Derivation for ${options.subject} has no value source. Specify 'value', 'expression', 'functionName', or 'fn'.`);
11044
11967
  }
11045
11968
 
11046
11969
  /**
@@ -11443,7 +12366,7 @@ function getEntriesForChangedFields$1(entries, changedFields) {
11443
12366
  function tryApplyDerivation(entry, context, chainContext) {
11444
12367
  const { derivationLogger } = context;
11445
12368
  // HTTP and async entries are processed asynchronously in their own streams — skip in sync loop
11446
- if (entry.http || entry.asyncFunctionName) {
12369
+ if (entry.http || entry.asyncFunctionName || entry.asyncFn) {
11447
12370
  return { applied: false, fieldKey: entry.fieldKey };
11448
12371
  }
11449
12372
  // Check if this is an array field derivation (has '$' placeholder)
@@ -11750,9 +12673,11 @@ function formatDerivationError(entry, phase, message) {
11750
12673
  ? `expression: "${entry.expression.substring(0, 50)}${entry.expression.length > 50 ? '...' : ''}"`
11751
12674
  : entry.functionName
11752
12675
  ? `function: "${entry.functionName}"`
11753
- : entry.value !== undefined
11754
- ? 'static value'
11755
- : 'unknown source';
12676
+ : entry.fn
12677
+ ? 'inline fn'
12678
+ : entry.value !== undefined
12679
+ ? 'static value'
12680
+ : 'unknown source';
11756
12681
  return `${ERROR_PREFIX$1} Failed to ${phase} derivation (field: ${entry.fieldKey}, ${source}): ${message}`;
11757
12682
  }
11758
12683
  /**
@@ -11821,7 +12746,7 @@ function shouldSkipForUserOverride(entry, resolvedFieldKey, context, chainContex
11821
12746
  function applyDerivationsForTrigger(collection, trigger, context, changedFields) {
11822
12747
  // Filter entries by trigger type, excluding HTTP and async entries (processed in async streams)
11823
12748
  const filteredEntries = collection.entries.filter((entry) => {
11824
- if (entry.http || entry.asyncFunctionName)
12749
+ if (entry.http || entry.asyncFunctionName || entry.asyncFn)
11825
12750
  return false;
11826
12751
  if (trigger === 'onChange') {
11827
12752
  return !entry.trigger || entry.trigger === 'onChange';
@@ -11990,7 +12915,7 @@ function topologicalSort(entries) {
11990
12915
  * Combines:
11991
12916
  * - Explicit `dependsOn` (when provided, it takes precedence over auto-detection)
11992
12917
  * - Expression-detected dependencies (only used when `dependsOn` is empty)
11993
- * - `'*'` wildcard for `functionName` (only when `dependsOn` is empty)
12918
+ * - `'*'` wildcard for `functionName` / inline `fn` (only when `dependsOn` is empty)
11994
12919
  * - Condition expression dependencies (always included)
11995
12920
  *
11996
12921
  * @internal
@@ -12004,7 +12929,9 @@ function extractDependenciesFromConfig(config) {
12004
12929
  if (config.expression) {
12005
12930
  extractStringDependencies(config.expression).forEach((dep) => deps.add(dep));
12006
12931
  }
12007
- if (config.functionName) {
12932
+ // Inline `fn` mirrors `functionName` semantics: without explicit dependsOn,
12933
+ // assume the function may depend on anything in the form.
12934
+ if (config.functionName || config.fn) {
12008
12935
  deps.add('*');
12009
12936
  }
12010
12937
  }
@@ -12014,6 +12941,32 @@ function extractDependenciesFromConfig(config) {
12014
12941
  return Array.from(deps);
12015
12942
  }
12016
12943
 
12944
+ /**
12945
+ * Reads the optional inline `fn` property off a derivation config.
12946
+ *
12947
+ * `fn` only appears on the function-mode branch of the discriminated union, so
12948
+ * call sites that need to copy it through unconditionally must widen via cast.
12949
+ * This helper centralises the cast and keeps it read-only — no widening, no
12950
+ * mutation. Returns `undefined` for any non-function-mode variant.
12951
+ *
12952
+ * @internal
12953
+ */
12954
+ function extractInlineFn(config) {
12955
+ return config.fn;
12956
+ }
12957
+ /**
12958
+ * Reads the optional inline `asyncFn` property off a derivation config.
12959
+ *
12960
+ * Same rationale as {@link extractInlineFn}: `asyncFn` only appears on the
12961
+ * `source: 'asyncFunction'` branch, so collectors that copy it through need a
12962
+ * cast. Read-only access, no widening.
12963
+ *
12964
+ * @internal
12965
+ */
12966
+ function extractInlineAsyncFn(config) {
12967
+ return config.asyncFn;
12968
+ }
12969
+
12017
12970
  /**
12018
12971
  * Recursively walks a field-definition tree, invoking `visitor` for each field.
12019
12972
  *
@@ -12356,9 +13309,11 @@ function createLogicEntry(fieldKey, config, context) {
12356
13309
  value: config.value,
12357
13310
  expression: config.expression,
12358
13311
  functionName: config.functionName,
13312
+ fn: extractInlineFn(config),
12359
13313
  http: config.http,
12360
13314
  responseExpression: config.responseExpression,
12361
13315
  asyncFunctionName: config.asyncFunctionName,
13316
+ asyncFn: extractInlineAsyncFn(config),
12362
13317
  trigger,
12363
13318
  debounceMs,
12364
13319
  isShorthand: false,
@@ -12674,21 +13629,67 @@ function validateNoCycles(collection, logger) {
12674
13629
  }
12675
13630
 
12676
13631
  /**
12677
- * Creates a staleness guard for HTTP/async derivation streams.
13632
+ * Compares two entry lists as multisets of content signatures. Order-insensitive
13633
+ * — entries reordered with the same content compare equal, so topological-sort
13634
+ * reorderings triggered by unrelated entries don't churn downstream stream
13635
+ * lifecycles.
13636
+ *
13637
+ * Multiset (not set) semantics: two distinct entries with identical signatures
13638
+ * are counted separately, so `[A, B]` does NOT equal `[A, A]` even though both
13639
+ * have length 2 and both contain `A` as a signature.
12678
13640
  *
12679
- * - Call `invalidate()` when tearing down the current generation of streams.
12680
- * - Pipe HTTP requests through `takeUntil(guard$)` to automatically discard
12681
- * in-flight responses that arrive after the generation has been invalidated.
13641
+ * Used by the value-derivation and property-derivation orchestrators to gate
13642
+ * `distinctUntilChanged` over HTTP- and async-function-entry sets.
13643
+ *
13644
+ * @internal
12682
13645
  */
12683
- function createStreamGuard() {
12684
- const subject = new Subject();
12685
- return {
12686
- invalidate: () => subject.next(),
12687
- guard$: subject.asObservable().pipe(take$1(1)),
12688
- };
13646
+ function entrySetsEqual(prev, next, sig) {
13647
+ if (prev.length !== next.length)
13648
+ return false;
13649
+ const counts = new Map();
13650
+ for (const item of prev) {
13651
+ const key = sig(item);
13652
+ counts.set(key, (counts.get(key) ?? 0) + 1);
13653
+ }
13654
+ for (const item of next) {
13655
+ const key = sig(item);
13656
+ const remaining = counts.get(key);
13657
+ if (!remaining)
13658
+ return false;
13659
+ if (remaining === 1)
13660
+ counts.delete(key);
13661
+ else
13662
+ counts.set(key, remaining - 1);
13663
+ }
13664
+ return counts.size === 0;
12689
13665
  }
12690
- const LOG_PREFIX$1 = 'HTTP Derivation -';
12691
- const DEFAULT_HTTP_DEBOUNCE_MS = 300;
13666
+ /**
13667
+ * Declarative pipeline shared by both derivation orchestrators:
13668
+ *
13669
+ * 1. Project the collection down to a typed entry list.
13670
+ * 2. Skip rebuilds when the entry set hasn't changed (multiset comparison).
13671
+ * 3. `switchMap` cancels the previous merged inner streams and creates fresh
13672
+ * ones from the new entries; in-flight HTTP / async work is cancelled via
13673
+ * ordinary RxJS unsubscribe semantics.
13674
+ *
13675
+ * No mutable subscription bookkeeping; the caller pipes through
13676
+ * `takeUntilDestroyed` for unmount cleanup.
13677
+ *
13678
+ * @internal
13679
+ */
13680
+ function buildEntryStreamPipeline(config) {
13681
+ return config.collection$.pipe(map((collection) => config.selectEntries(collection)), distinctUntilChanged((prev, next) => entrySetsEqual(prev, next, config.entrySignature)), switchMap((entries) => {
13682
+ if (entries.length === 0)
13683
+ return EMPTY;
13684
+ const streams = entries.map((entry) => config.createStream(entry)).filter((stream) => stream !== null);
13685
+ if (streams.length === 0)
13686
+ return EMPTY;
13687
+ return merge(...streams);
13688
+ }));
13689
+ }
13690
+
13691
+ const LOG_PREFIX$3 = 'HTTP Derivation -';
13692
+ const DEFAULT_HTTP_DEBOUNCE_MS$1 = 300;
12692
13693
  /**
12693
13694
  * Creates an RxJS Observable stream that processes an HTTP derivation entry.
12694
13695
  *
@@ -12712,7 +13713,7 @@ const DEFAULT_HTTP_DEBOUNCE_MS = 300;
12712
13713
  function createHttpDerivationStream(entry, formValue$, context) {
12713
13714
  // Guard: no array support for HTTP derivations
12714
13715
  if (isArrayPlaceholderPath(entry.fieldKey)) {
12715
- context.logger.warn(`${LOG_PREFIX$1} HTTP derivation for array field '${entry.fieldKey}' is not supported. ` +
13716
+ context.logger.warn(`${LOG_PREFIX$3} HTTP derivation for array field '${entry.fieldKey}' is not supported. ` +
12716
13717
  `Array HTTP derivations will be supported in a future release.`);
12717
13718
  return EMPTY;
12718
13719
  }
@@ -12721,7 +13722,7 @@ function createHttpDerivationStream(entry, formValue$, context) {
12721
13722
  }
12722
13723
  // Capture after the guard — TS narrows this to `string` here
12723
13724
  const responseExpression = entry.responseExpression;
12724
- const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;
13725
+ const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS$1;
12725
13726
  return formValue$.pipe(startWith(null), pairwise(), map(([previous, current]) => ({
12726
13727
  current: current,
12727
13728
  changedFields: getChangedKeys(previous, current),
@@ -12815,15 +13816,16 @@ function createHttpDerivationStream(entry, formValue$, context) {
12815
13816
  subscriber.complete();
12816
13817
  return;
12817
13818
  }
12818
- // Make the HTTP request. takeUntil(guard$) automatically discards responses
12819
- // that arrive after the config has changed and the guard has been invalidated.
13819
+ // Cancellation is handled declaratively by the orchestrator's outer
13820
+ // switchMap: when the entry set changes, the outer stream unsubscribes
13821
+ // from this inner observable, the cleanup callback below runs, and
13822
+ // the in-flight HTTP request is cancelled.
12820
13823
  const method = (resolvedRequest.method ?? 'GET').toUpperCase();
12821
13824
  const httpSub = context.httpClient
12822
13825
  .request(method, resolvedRequest.url, {
12823
13826
  body: resolvedRequest.body,
12824
13827
  headers: resolvedRequest.headers,
12825
13828
  })
12826
- .pipe(takeUntil(context.guard$))
12827
13829
  .subscribe({
12828
13830
  next: (response) => {
12829
13831
  try {
@@ -12858,13 +13860,13 @@ function createHttpDerivationStream(entry, formValue$, context) {
12858
13860
  }
12859
13861
  catch (error) {
12860
13862
  const message = error instanceof Error ? error.message : String(error);
12861
- context.logger.warn(`${LOG_PREFIX$1} Failed to process response for '${entry.fieldKey}': ${message}`);
13863
+ context.logger.warn(`${LOG_PREFIX$3} Failed to process response for '${entry.fieldKey}': ${message}`);
12862
13864
  subscriber.complete();
12863
13865
  }
12864
13866
  },
12865
13867
  error: (error) => {
12866
13868
  const message = error instanceof Error ? error.message : String(error);
12867
- context.logger.warn(`${LOG_PREFIX$1} HTTP request failed for '${entry.fieldKey}': ${message}`);
13869
+ context.logger.warn(`${LOG_PREFIX$3} HTTP request failed for '${entry.fieldKey}': ${message}`);
12868
13870
  subscriber.complete();
12869
13871
  },
12870
13872
  });
@@ -12878,13 +13880,13 @@ function createHttpDerivationStream(entry, formValue$, context) {
12878
13880
  }), catchError$1((error) => {
12879
13881
  // Safety net — should not normally be reached since inner errors are caught
12880
13882
  const message = error instanceof Error ? error.message : String(error);
12881
- context.logger.warn(`${LOG_PREFIX$1} Unexpected stream error for '${entry.fieldKey}': ${message}`);
13883
+ context.logger.warn(`${LOG_PREFIX$3} Unexpected stream error for '${entry.fieldKey}': ${message}`);
12882
13884
  return EMPTY;
12883
13885
  }));
12884
13886
  }
12885
13887
 
12886
- const LOG_PREFIX = 'Async Derivation -';
12887
- const DEFAULT_ASYNC_DEBOUNCE_MS = 300;
13888
+ const LOG_PREFIX$2 = 'Async Derivation -';
13889
+ const DEFAULT_ASYNC_DEBOUNCE_MS$1 = 300;
12888
13890
  /**
12889
13891
  * Creates an RxJS Observable stream that processes an async derivation entry.
12890
13892
  *
@@ -12903,15 +13905,22 @@ const DEFAULT_ASYNC_DEBOUNCE_MS = 300;
12903
13905
  function createAsyncDerivationStream(entry, formValue$, context) {
12904
13906
  // Guard: no array support for async derivations
12905
13907
  if (isArrayPlaceholderPath(entry.fieldKey)) {
12906
- context.logger.warn(`${LOG_PREFIX} Async derivation for array field '${entry.fieldKey}' is not supported. ` +
13908
+ context.logger.warn(`${LOG_PREFIX$2} Async derivation for array field '${entry.fieldKey}' is not supported. ` +
12907
13909
  `Array async derivations will be supported in a future release.`);
12908
13910
  return EMPTY;
12909
13911
  }
12910
- if (!entry.asyncFunctionName) {
13912
+ if (!entry.asyncFunctionName && !entry.asyncFn) {
13913
+ context.logger.warn(`${LOG_PREFIX$2} Derivation for '${entry.fieldKey}' has neither "asyncFunctionName" nor "asyncFn". ` +
13914
+ `One of the two is required for source: 'asyncFunction'.`);
12911
13915
  return EMPTY;
12912
13916
  }
13917
+ if (entry.asyncFn && entry.asyncFunctionName) {
13918
+ context.logger.warn(`${LOG_PREFIX$2} Both "asyncFn" and "asyncFunctionName" are set on derivation for '${entry.fieldKey}'. ` +
13919
+ `Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.`);
13920
+ }
13921
+ const inlineAsyncFn = entry.asyncFn;
12913
13922
  const asyncFunctionName = entry.asyncFunctionName;
12914
- const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;
13923
+ const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS$1;
12915
13924
  // startWith(null) → pairwise() causes all fields to appear "changed" on the
12916
13925
  // first emission (null vs actual form value), so every dependsOn field matches.
12917
13926
  // This intentionally fires the async derivation on initial form load — same
@@ -12955,10 +13964,10 @@ function createAsyncDerivationStream(entry, formValue$, context) {
12955
13964
  const customFunctions = context.customFunctions?.();
12956
13965
  const asyncDerivationFunctions = context.asyncDerivationFunctions?.();
12957
13966
  const externalData = context.externalData?.();
12958
- // Look up the async function
12959
- const asyncFn = asyncDerivationFunctions?.[asyncFunctionName];
13967
+ // Inline `asyncFn` wins; otherwise look up the registered function.
13968
+ const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);
12960
13969
  if (!asyncFn) {
12961
- context.logger.warn(`${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +
13970
+ context.logger.warn(`${LOG_PREFIX$2} Async derivation function '${asyncFunctionName}' not found for field '${entry.fieldKey}'. ` +
12962
13971
  `Register it in customFnConfig.asyncDerivations.`);
12963
13972
  subscriber.complete();
12964
13973
  return;
@@ -13026,13 +14035,13 @@ function createAsyncDerivationStream(entry, formValue$, context) {
13026
14035
  }
13027
14036
  catch (error) {
13028
14037
  const message = error instanceof Error ? error.message : String(error);
13029
- context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}': ${message}`);
14038
+ context.logger.warn(`${LOG_PREFIX$2} Failed to process result for '${entry.fieldKey}': ${message}`);
13030
14039
  subscriber.complete();
13031
14040
  }
13032
14041
  },
13033
14042
  error: (error) => {
13034
14043
  const message = error instanceof Error ? error.message : String(error);
13035
- context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}': ${message}`);
14044
+ context.logger.warn(`${LOG_PREFIX$2} Async function failed for '${entry.fieldKey}': ${message}`);
13036
14045
  subscriber.complete();
13037
14046
  },
13038
14047
  complete: () => {
@@ -13049,7 +14058,7 @@ function createAsyncDerivationStream(entry, formValue$, context) {
13049
14058
  }), catchError$1((error) => {
13050
14059
  // Safety net — should not normally be reached since inner errors are caught
13051
14060
  const message = error instanceof Error ? error.message : String(error);
13052
- context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}': ${message}`);
14061
+ context.logger.warn(`${LOG_PREFIX$2} Unexpected stream error for '${entry.fieldKey}': ${message}`);
13053
14062
  return EMPTY;
13054
14063
  }));
13055
14064
  }
@@ -13127,20 +14136,6 @@ class DerivationOrchestrator {
13127
14136
  functionRegistry = inject(FunctionRegistryService);
13128
14137
  formOptions = inject(FORM_OPTIONS);
13129
14138
  httpClient = inject(HttpClient, { optional: true });
13130
- /** Active HTTP derivation stream subscriptions */
13131
- httpSubscriptions = [];
13132
- /** Identity keys of current HTTP entries for smart teardown comparison */
13133
- lastHttpEntryKeys = null;
13134
- /** Active async function derivation stream subscriptions */
13135
- asyncFunctionSubscriptions = [];
13136
- /** Identity keys of current async entries for smart teardown comparison */
13137
- lastAsyncEntryKeys = null;
13138
- /**
13139
- * Guard for detecting stale HTTP responses. Call `invalidate()` on teardown to cancel
13140
- * in-flight requests from the previous generation. Each HTTP stream subscribes via
13141
- * `takeUntil(guard$)` — when `invalidate()` fires, all in-flight requests are discarded.
13142
- */
13143
- streamGuard = createStreamGuard();
13144
14139
  /**
13145
14140
  * Computed signal containing the collected and validated derivations.
13146
14141
  * Returns null if no derivations are defined.
@@ -13296,36 +14291,33 @@ class DerivationOrchestrator {
13296
14291
  }
13297
14292
  }
13298
14293
  /**
13299
- * Sets up reactive HTTP derivation streams that react to collection changes.
14294
+ * Builds a declarative stream that reacts to changes in the set of HTTP
14295
+ * derivation entries and emits per-entry streams merged together.
14296
+ *
14297
+ * - `distinctUntilChanged` over the set of entry signatures skips rebuilds
14298
+ * when the HTTP entries haven't actually changed. The comparison is
14299
+ * order-insensitive — `topologicalSort` in the collector can reorder
14300
+ * existing entries when unrelated entries are added/removed, and we
14301
+ * don't want to tear down healthy streams in that case.
14302
+ * - `switchMap` cancels the previous merged stream when entries change,
14303
+ * which propagates into each `createHttpDerivationStream` and cancels
14304
+ * any in-flight HTTP requests via ordinary RxJS unsubscribe semantics.
13300
14305
  *
13301
- * Subscribes to derivationCollection changes and creates per-entry HTTP streams.
13302
- * Uses smart teardown: only recreates streams when HTTP entries actually change.
14306
+ * No mutable subscription bookkeeping or stream guard — `takeUntilDestroyed`
14307
+ * on the outer subscription handles unmount.
13303
14308
  */
13304
14309
  setupHttpStreams() {
13305
- toObservable(this.derivationCollection, { injector: this.injector })
13306
- .pipe(takeUntilDestroyed(this.destroyRef))
13307
- .subscribe((collection) => {
13308
- const httpEntries = collection?.entries.filter((entry) => entry.http) ?? [];
13309
- if (httpEntries.length === 0) {
13310
- this.teardownHttpStreams();
13311
- this.lastHttpEntryKeys = null;
13312
- return;
13313
- }
13314
- // Smart teardown: compare entry identity keys to avoid redundant stream recreation
13315
- const newKeys = this.computeHttpEntryKeys(httpEntries);
13316
- if (this.lastHttpEntryKeys && this.setsEqual(this.lastHttpEntryKeys, newKeys)) {
13317
- return; // No change in HTTP entries — keep existing streams
13318
- }
13319
- // Validate HttpClient availability
13320
- if (!this.httpClient) {
13321
- this.logger.error('HTTP Derivation: HttpClient is not available. ' + 'Ensure provideHttpClient() is included in your application providers.');
13322
- return;
13323
- }
13324
- // Tear down previous streams and create new ones
13325
- this.teardownHttpStreams();
13326
- this.lastHttpEntryKeys = newKeys;
13327
- const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
13328
- for (const entry of httpEntries) {
14310
+ const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
14311
+ const collection$ = toObservable(this.derivationCollection, { injector: this.injector });
14312
+ buildEntryStreamPipeline({
14313
+ collection$,
14314
+ selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],
14315
+ entrySignature: httpEntrySignature$1,
14316
+ createStream: (entry) => {
14317
+ if (!this.httpClient) {
14318
+ this.logger.error('HTTP Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.');
14319
+ return null;
14320
+ }
13329
14321
  const context = {
13330
14322
  formValue: this.config.formValue,
13331
14323
  form: this.config.form,
@@ -13335,114 +14327,77 @@ class DerivationOrchestrator {
13335
14327
  customFunctions: () => this.functionRegistry.getCustomFunctions(),
13336
14328
  externalData: () => resolveExternalData(this.config.externalData),
13337
14329
  warningTracker: this.warningTracker,
13338
- guard$: this.streamGuard.guard$,
13339
14330
  };
13340
- const stream = createHttpDerivationStream(entry, formValue$, context).pipe(takeUntilDestroyed(this.destroyRef));
13341
- this.httpSubscriptions.push(stream.subscribe({
13342
- error: (err) => this.logger.error(`HTTP Derivation: Stream error for '${entry.fieldKey}'`, err),
13343
- }));
13344
- }
13345
- });
13346
- }
13347
- /**
13348
- * Tears down all active HTTP derivation streams. Invalidating the stream guard
13349
- * cancels any in-flight HTTP responses from the old generation via `takeUntil`.
13350
- */
13351
- teardownHttpStreams() {
13352
- this.streamGuard.invalidate();
13353
- for (const sub of this.httpSubscriptions) {
13354
- sub.unsubscribe();
13355
- }
13356
- this.httpSubscriptions = [];
13357
- }
13358
- /**
13359
- * Computes a set of identity keys for HTTP entries.
13360
- * Used for smart teardown comparison.
13361
- */
13362
- computeHttpEntryKeys(entries) {
13363
- return new Set(entries.map((entry) => `${entry.fieldKey}:${JSON.stringify(entry.http, Object.keys(entry.http ?? {}).sort())}`));
13364
- }
13365
- /**
13366
- * Compares two sets for equality.
13367
- */
13368
- setsEqual(a, b) {
13369
- if (a.size !== b.size)
13370
- return false;
13371
- for (const item of a) {
13372
- if (!b.has(item))
13373
- return false;
13374
- }
13375
- return true;
14331
+ return createHttpDerivationStream(entry, formValue$, context);
14332
+ },
14333
+ })
14334
+ .pipe(catchError((err) => {
14335
+ this.logger.error('HTTP Derivation: outer stream error', err);
14336
+ return EMPTY;
14337
+ }), takeUntilDestroyed(this.destroyRef))
14338
+ .subscribe();
13376
14339
  }
13377
- /**
13378
- * Sets up reactive async function derivation streams that react to collection changes.
13379
- *
13380
- * Subscribes to derivationCollection changes and creates per-entry async streams.
13381
- * Uses smart teardown: only recreates streams when async entries actually change.
13382
- */
13383
14340
  setupAsyncFunctionStreams() {
13384
- toObservable(this.derivationCollection, { injector: this.injector })
13385
- .pipe(takeUntilDestroyed(this.destroyRef))
13386
- .subscribe((collection) => {
13387
- const asyncEntries = collection?.entries.filter((entry) => entry.asyncFunctionName) ?? [];
13388
- if (asyncEntries.length === 0) {
13389
- this.teardownAsyncFunctionStreams();
13390
- this.lastAsyncEntryKeys = null;
13391
- return;
13392
- }
13393
- // Smart teardown: compare entry identity keys to avoid redundant stream recreation
13394
- const newKeys = this.computeAsyncEntryKeys(asyncEntries);
13395
- if (this.lastAsyncEntryKeys && this.setsEqual(this.lastAsyncEntryKeys, newKeys)) {
13396
- return; // No change in async entries — keep existing streams
13397
- }
13398
- // Tear down previous streams and create new ones
13399
- this.teardownAsyncFunctionStreams();
13400
- this.lastAsyncEntryKeys = newKeys;
13401
- const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
13402
- for (const entry of asyncEntries) {
13403
- const context = {
13404
- formValue: this.config.formValue,
13405
- form: this.config.form,
13406
- logger: this.logger,
13407
- derivationLogger: this.config.derivationLogger,
13408
- customFunctions: () => this.functionRegistry.getCustomFunctions(),
13409
- asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),
13410
- externalData: () => resolveExternalData(this.config.externalData),
13411
- warningTracker: this.warningTracker,
13412
- };
13413
- const stream = createAsyncDerivationStream(entry, formValue$, context).pipe(takeUntilDestroyed(this.destroyRef));
13414
- this.asyncFunctionSubscriptions.push(stream.subscribe({
13415
- error: (err) => this.logger.error(`Async Derivation: Stream error for '${entry.fieldKey}'`, err),
13416
- }));
13417
- }
13418
- });
13419
- }
13420
- /**
13421
- * Tears down all active async function derivation streams.
13422
- */
13423
- teardownAsyncFunctionStreams() {
13424
- for (const sub of this.asyncFunctionSubscriptions) {
13425
- sub.unsubscribe();
13426
- }
13427
- this.asyncFunctionSubscriptions = [];
13428
- }
13429
- /**
13430
- * Computes a set of identity keys for async entries.
13431
- * Used for smart teardown comparison.
13432
- */
13433
- computeAsyncEntryKeys(entries) {
13434
- return new Set(entries.map((entry) => {
13435
- const config = {
13436
- asyncFunctionName: entry.asyncFunctionName,
13437
- dependsOn: entry.dependsOn,
13438
- debounceMs: entry.debounceMs,
13439
- stopOnUserOverride: entry.stopOnUserOverride,
13440
- reEngageOnDependencyChange: entry.reEngageOnDependencyChange,
13441
- };
13442
- return `${entry.fieldKey}:${JSON.stringify(config, Object.keys(config).sort())}`;
13443
- }));
14341
+ const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
14342
+ const collection$ = toObservable(this.derivationCollection, { injector: this.injector });
14343
+ buildEntryStreamPipeline({
14344
+ collection$,
14345
+ selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],
14346
+ entrySignature: asyncEntrySignature$1,
14347
+ createStream: (entry) => createAsyncDerivationStream(entry, formValue$, {
14348
+ formValue: this.config.formValue,
14349
+ form: this.config.form,
14350
+ logger: this.logger,
14351
+ derivationLogger: this.config.derivationLogger,
14352
+ customFunctions: () => this.functionRegistry.getCustomFunctions(),
14353
+ asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),
14354
+ externalData: () => resolveExternalData(this.config.externalData),
14355
+ warningTracker: this.warningTracker,
14356
+ }),
14357
+ })
14358
+ .pipe(catchError((err) => {
14359
+ this.logger.error('Async Derivation: outer stream error', err);
14360
+ return EMPTY;
14361
+ }), takeUntilDestroyed(this.destroyRef))
14362
+ .subscribe();
13444
14363
  }
13445
14364
  }
14365
+ /**
14366
+ * Identity signature for an HTTP derivation entry. Includes every field that
14367
+ * drives the inner stream's behavior — changing any of them must tear down
14368
+ * and rebuild the stream. Stable across topological reorderings because the
14369
+ * consumer ({@link entrySetsEqual}) compares as a multiset.
14370
+ */
14371
+ function httpEntrySignature$1(entry) {
14372
+ const config = {
14373
+ http: entry.http,
14374
+ responseExpression: entry.responseExpression,
14375
+ dependsOn: entry.dependsOn,
14376
+ debounceMs: entry.debounceMs,
14377
+ trigger: entry.trigger,
14378
+ condition: entry.condition,
14379
+ stopOnUserOverride: entry.stopOnUserOverride,
14380
+ reEngageOnDependencyChange: entry.reEngageOnDependencyChange,
14381
+ };
14382
+ return `${entry.fieldKey}:${JSON.stringify(config)}`;
14383
+ }
14384
+ function asyncEntrySignature$1(entry) {
14385
+ // For inline `asyncFn` entries we tag the signature with the field path (the
14386
+ // real identity, since derivations are self-targeting) rather than the array
14387
+ // index. Index-based tagging churned identities whenever topologicalSort
14388
+ // reordered entries, causing unnecessary teardown + re-subscribe.
14389
+ const config = {
14390
+ asyncFunctionName: entry.asyncFunctionName,
14391
+ asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}` : undefined,
14392
+ dependsOn: entry.dependsOn,
14393
+ debounceMs: entry.debounceMs,
14394
+ trigger: entry.trigger,
14395
+ condition: entry.condition,
14396
+ stopOnUserOverride: entry.stopOnUserOverride,
14397
+ reEngageOnDependencyChange: entry.reEngageOnDependencyChange,
14398
+ };
14399
+ return `${entry.fieldKey}:${JSON.stringify(config)}`;
14400
+ }
13446
14401
  /**
13447
14402
  * Creates a DerivationOrchestrator for a dynamic form.
13448
14403
  * Must be called within an injection context.
@@ -13482,10 +14437,11 @@ function warnAboutWildcardDependencies$1(logger, entries, fieldCount) {
13482
14437
  // HTTP and async entries require explicit dependsOn (validated at collection time)
13483
14438
  const implicitWildcards = wildcardEntries.filter((entry) => !entry.http &&
13484
14439
  !entry.asyncFunctionName &&
13485
- entry.functionName &&
14440
+ !entry.asyncFn &&
14441
+ (entry.functionName || entry.fn) &&
13486
14442
  (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
13487
14443
  if (implicitWildcards.length > 0) {
13488
- const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName})`);
14444
+ const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey} (${e.functionName ?? '<inline fn>'})`);
13489
14445
  logger.warn('Derivation: custom functions without explicit dependsOn detected. ' +
13490
14446
  `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
13491
14447
  'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
@@ -13649,7 +14605,11 @@ function isDynamicFormFeature(value) {
13649
14605
  Array.isArray(value.ɵproviders));
13650
14606
  }
13651
14607
  /**
13652
- * Helper to create a feature with proper typing
14608
+ * Helper to create a feature with proper typing.
14609
+ *
14610
+ * `kind` is the discriminant compared by string equality at the
14611
+ * provider-resolution site (and in feature-specific type guards). Each
14612
+ * feature module owns its own kind literal — no central registry.
13653
14613
  */
13654
14614
  function createFeature(kind, providers) {
13655
14615
  return {
@@ -13745,6 +14705,27 @@ function withLoggerConfig(config = true) {
13745
14705
  return createFeature('logger', providers);
13746
14706
  }
13747
14707
 
14708
+ /**
14709
+ * Creates an empty property derivation collection.
14710
+ *
14711
+ * @returns Empty collection
14712
+ *
14713
+ * @internal
14714
+ */
14715
+ function createEmptyPropertyDerivationCollection() {
14716
+ return { entries: [] };
14717
+ }
14718
+ /**
14719
+ * Returns true when the entry's value source is async (HTTP or async function)
14720
+ * and therefore must be processed by a dedicated stream subscription rather
14721
+ * than the synchronous applicator pipeline.
14722
+ *
14723
+ * @internal
14724
+ */
14725
+ function isAsyncPropertyDerivationEntry(entry) {
14726
+ return Boolean(entry.http) || Boolean(entry.asyncFunctionName) || Boolean(entry.asyncFn);
14727
+ }
14728
+
13748
14729
  /** @internal */
13749
14730
  const ERROR_PREFIX = '[PropertyDerivation]';
13750
14731
  /**
@@ -13765,7 +14746,10 @@ function applyPropertyDerivations(collection, context, changedFields) {
13765
14746
  let appliedCount = 0;
13766
14747
  let skippedCount = 0;
13767
14748
  let errorCount = 0;
13768
- const entriesToProcess = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;
14749
+ const baseEntries = changedFields ? getEntriesForChangedFields(collection.entries, changedFields) : collection.entries;
14750
+ // Async entries (HTTP / asyncFn / asyncFunctionName) are processed by dedicated
14751
+ // stream subscriptions in the orchestrator, not by this synchronous applicator.
14752
+ const entriesToProcess = baseEntries.filter((entry) => !isAsyncPropertyDerivationEntry(entry));
13769
14753
  for (const entry of entriesToProcess) {
13770
14754
  try {
13771
14755
  const applied = tryApplyPropertyDerivation(entry, context);
@@ -13968,8 +14952,16 @@ function collectPropertyDerivations(fields, logger, tracker) {
13968
14952
  const entries = [];
13969
14953
  const context = { logger, tracker };
13970
14954
  traverseFieldsWithContext(fields, context, (field, ctx) => collectFromField(field, entries, ctx), {
13971
- onArrayChild: (_, field) => ({ arrayPath: field.key }),
13972
- // Group and layout containers do not affect property-derivation paths.
14955
+ // Array boundary: each item is its own model root. Reset groupPath so
14956
+ // ancestor groups OUTSIDE the array don't bleed into per-item keys.
14957
+ onArrayChild: (_, field) => ({ arrayPath: field.key, groupPath: undefined }),
14958
+ onGroupChild: (parent, field) => {
14959
+ // Keyless groups (rare) act like layout containers — pass through.
14960
+ if (!field.key)
14961
+ return {};
14962
+ return { groupPath: parent.groupPath ? `${parent.groupPath}.${field.key}` : field.key };
14963
+ },
14964
+ // Layout containers (page, row, container) leave context unchanged.
13973
14965
  });
13974
14966
  return { entries };
13975
14967
  }
@@ -14001,7 +14993,79 @@ function collectFromField(field, entries, context) {
14001
14993
  * @internal
14002
14994
  */
14003
14995
  function createPropertyDerivationEntryFromDerivation(fieldKey, config, context) {
14004
- const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey);
14996
+ const effectiveFieldKey = buildPropertyOverrideKey(context.arrayPath, context.arrayPath ? PLACEHOLDER_INDEX : undefined, fieldKey, context.groupPath);
14997
+ // HTTP and async function property derivations require explicit, non-wildcard
14998
+ // dependencies — mirrors the same guards on value-derivation HTTP/async sources.
14999
+ //
15000
+ // Type-level discriminants require these fields, but JSON-loaded configs can
15001
+ // bypass that — so we defensively guard against missing arrays and missing
15002
+ // HTTP fields here to fail fast with `DynamicFormError` instead of throwing
15003
+ // an opaque `TypeError` later.
15004
+ //
15005
+ // `targetProperty` is captured up front and `untypedConfig` is used inside
15006
+ // the source-narrowed blocks because TypeScript narrows `config` to `never`
15007
+ // once we negate a discriminant-required field (e.g. `!config.http` when
15008
+ // `source: 'http'` requires `http`).
15009
+ const errorLocation = `'${effectiveFieldKey}.${config.targetProperty}'`;
15010
+ const untypedConfig = config;
15011
+ // Reject configs that mix value-source fields with an explicit async source.
15012
+ // TypeScript's discriminated union already enforces this XOR for inline
15013
+ // configs; the runtime check guards JSON-loaded configs (API/OpenAPI/MCP)
15014
+ // where the wrong combination would silently degrade — async entries are
15015
+ // routed to the stream pipeline while sync fields would be ignored.
15016
+ if (untypedConfig.source === 'http' || untypedConfig.source === 'asyncFunction') {
15017
+ const conflictingSyncSources = [];
15018
+ if (untypedConfig.value !== undefined)
15019
+ conflictingSyncSources.push('value');
15020
+ if (untypedConfig.expression !== undefined)
15021
+ conflictingSyncSources.push('expression');
15022
+ if (untypedConfig.functionName !== undefined)
15023
+ conflictingSyncSources.push('functionName');
15024
+ if (untypedConfig.fn !== undefined)
15025
+ conflictingSyncSources.push('fn');
15026
+ if (conflictingSyncSources.length > 0) {
15027
+ throw new DynamicFormError(`Property derivation for ${errorLocation} sets 'source: ${JSON.stringify(untypedConfig.source)}' alongside ` +
15028
+ `sync value source(s) [${conflictingSyncSources.join(', ')}]. These are mutually exclusive — remove either ` +
15029
+ `the sync fields or the async source.`);
15030
+ }
15031
+ }
15032
+ // Reject HTTP + asyncFunction-field combinations as well (TS already
15033
+ // enforces this; runtime guard for JSON-loaded configs).
15034
+ if (untypedConfig.source === 'http' && (untypedConfig.asyncFunctionName !== undefined || untypedConfig.asyncFn !== undefined)) {
15035
+ throw new DynamicFormError(`Property derivation for ${errorLocation} sets 'source: "http"' alongside an async function — these are mutually exclusive.`);
15036
+ }
15037
+ if (untypedConfig.source === 'asyncFunction' && (untypedConfig.http !== undefined || untypedConfig.responseExpression !== undefined)) {
15038
+ throw new DynamicFormError(`Property derivation for ${errorLocation} sets 'source: "asyncFunction"' alongside HTTP fields — these are mutually exclusive.`);
15039
+ }
15040
+ if (untypedConfig.source === 'http') {
15041
+ if (!untypedConfig.http) {
15042
+ throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires an 'http' config object.`);
15043
+ }
15044
+ if (typeof untypedConfig.responseExpression !== 'string' || untypedConfig.responseExpression.trim() === '') {
15045
+ throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires a non-empty 'responseExpression'.`);
15046
+ }
15047
+ if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {
15048
+ throw new DynamicFormError(`HTTP property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +
15049
+ `Wildcard dependencies would trigger HTTP requests on every form change.`);
15050
+ }
15051
+ if (untypedConfig.dependsOn.includes('*')) {
15052
+ throw new DynamicFormError(`HTTP property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +
15053
+ `Wildcards would trigger HTTP requests on every form change. Specify explicit field dependencies instead.`);
15054
+ }
15055
+ }
15056
+ if (untypedConfig.source === 'asyncFunction') {
15057
+ if (!untypedConfig.asyncFunctionName && !untypedConfig.asyncFn) {
15058
+ throw new DynamicFormError(`Async property derivation for ${errorLocation} requires either 'asyncFunctionName' or 'asyncFn'.`);
15059
+ }
15060
+ if (!Array.isArray(untypedConfig.dependsOn) || untypedConfig.dependsOn.length === 0) {
15061
+ throw new DynamicFormError(`Async property derivation for ${errorLocation} requires explicit 'dependsOn'. ` +
15062
+ `Wildcard dependencies would trigger async functions on every form change.`);
15063
+ }
15064
+ if (untypedConfig.dependsOn.includes('*')) {
15065
+ throw new DynamicFormError(`Async property derivation for ${errorLocation} cannot use wildcard ('*') in 'dependsOn'. ` +
15066
+ `Wildcards would trigger async functions on every form change. Specify explicit field dependencies instead.`);
15067
+ }
15068
+ }
14005
15069
  const dependsOn = extractDependenciesFromConfig(config);
14006
15070
  const condition = config.condition ?? true;
14007
15071
  const trigger = config.trigger ?? 'onChange';
@@ -14014,6 +15078,11 @@ function createPropertyDerivationEntryFromDerivation(fieldKey, config, context)
14014
15078
  value: config.value,
14015
15079
  expression: config.expression,
14016
15080
  functionName: config.functionName,
15081
+ fn: extractInlineFn(config),
15082
+ http: config.http,
15083
+ responseExpression: config.responseExpression,
15084
+ asyncFunctionName: config.asyncFunctionName,
15085
+ asyncFn: extractInlineAsyncFn(config),
14017
15086
  trigger,
14018
15087
  debounceMs,
14019
15088
  debugName: config.debugName,
@@ -14021,6 +15090,204 @@ function createPropertyDerivationEntryFromDerivation(fieldKey, config, context)
14021
15090
  };
14022
15091
  }
14023
15092
 
15093
+ const LOG_PREFIX$1 = 'HTTP Property Derivation -';
15094
+ const DEFAULT_HTTP_DEBOUNCE_MS = 300;
15095
+ /**
15096
+ * Creates an RxJS Observable stream that processes an HTTP property-derivation entry.
15097
+ *
15098
+ * Mirrors `createHttpDerivationStream` (value pipeline) but writes the result via
15099
+ * `store.setOverride(fieldKey, targetProperty, value)` instead of patching the form.
15100
+ *
15101
+ * The stream uses `startWith(null) → pairwise()` so the first emission fires a
15102
+ * request for all `dependsOn` fields, populating the derived property on initial
15103
+ * form load. Subsequent emissions only fire when a `dependsOn` field changes.
15104
+ *
15105
+ * @internal
15106
+ */
15107
+ function createHttpPropertyDerivationStream(entry, formValue$, context) {
15108
+ // Guard: no array support yet for HTTP property derivations (mirrors value pipeline).
15109
+ if (isArrayPlaceholderPath(entry.fieldKey)) {
15110
+ context.logger.warn(`${LOG_PREFIX$1} HTTP property derivation for array field '${entry.fieldKey}' is not supported. ` +
15111
+ `Array HTTP property derivations will be supported in a future release.`);
15112
+ return EMPTY;
15113
+ }
15114
+ if (!entry.http || !entry.responseExpression) {
15115
+ return EMPTY;
15116
+ }
15117
+ const responseExpression = entry.responseExpression;
15118
+ const debounceMs = entry.debounceMs ?? DEFAULT_HTTP_DEBOUNCE_MS;
15119
+ return formValue$.pipe(startWith(null), pairwise(), map(([previous, current]) => ({
15120
+ current: current,
15121
+ changedFields: getChangedKeys(previous, current),
15122
+ })), filter(({ changedFields }) => {
15123
+ if (changedFields.size === 0)
15124
+ return false;
15125
+ return entry.dependsOn.some((dep) => changedFields.has(dep));
15126
+ }), debounceTime(debounceMs), switchMap(({ current }) => {
15127
+ return new Observable((subscriber) => {
15128
+ const customFunctions = context.customFunctions?.();
15129
+ const externalData = context.externalData?.();
15130
+ const fieldValue = getNestedValue(current, entry.fieldKey);
15131
+ const evalContext = {
15132
+ fieldValue,
15133
+ formValue: current,
15134
+ fieldPath: entry.fieldKey,
15135
+ customFunctions,
15136
+ externalData,
15137
+ logger: context.logger,
15138
+ };
15139
+ // Evaluate condition — skip if false
15140
+ if (entry.condition !== true) {
15141
+ const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);
15142
+ if (!conditionMet) {
15143
+ subscriber.complete();
15144
+ return;
15145
+ }
15146
+ }
15147
+ // Returns null when a path param is undefined — suppress the request
15148
+ const resolvedRequest = resolveHttpRequest(entry.http, evalContext);
15149
+ if (!resolvedRequest) {
15150
+ subscriber.complete();
15151
+ return;
15152
+ }
15153
+ const method = (resolvedRequest.method ?? 'GET').toUpperCase();
15154
+ // Inner cancellation is handled declaratively by the orchestrator's
15155
+ // outer switchMap: when the entry set changes, the outer stream
15156
+ // unsubscribes from this inner observable, which triggers the
15157
+ // cleanup callback below and cancels the in-flight HTTP request.
15158
+ const httpSub = context.httpClient
15159
+ .request(method, resolvedRequest.url, {
15160
+ body: resolvedRequest.body,
15161
+ headers: resolvedRequest.headers,
15162
+ })
15163
+ .subscribe({
15164
+ next: (response) => {
15165
+ try {
15166
+ const newValue = ExpressionParser.evaluate(responseExpression, { response });
15167
+ // `setOverride` performs its own deep-equality check, so we don't need to
15168
+ // compare with the current store value here.
15169
+ untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));
15170
+ subscriber.complete();
15171
+ }
15172
+ catch (error) {
15173
+ const message = error instanceof Error ? error.message : String(error);
15174
+ context.logger.warn(`${LOG_PREFIX$1} Failed to process response for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15175
+ subscriber.complete();
15176
+ }
15177
+ },
15178
+ error: (error) => {
15179
+ const message = error instanceof Error ? error.message : String(error);
15180
+ context.logger.warn(`${LOG_PREFIX$1} HTTP request failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15181
+ subscriber.complete();
15182
+ },
15183
+ });
15184
+ return () => {
15185
+ httpSub.unsubscribe();
15186
+ };
15187
+ });
15188
+ }), catchError$1((error) => {
15189
+ const message = error instanceof Error ? error.message : String(error);
15190
+ context.logger.warn(`${LOG_PREFIX$1} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15191
+ return EMPTY;
15192
+ }));
15193
+ }
15194
+
15195
+ const LOG_PREFIX = 'Async Property Derivation -';
15196
+ const DEFAULT_ASYNC_DEBOUNCE_MS = 300;
15197
+ /**
15198
+ * Creates an RxJS Observable stream that processes an async-function property-derivation entry.
15199
+ *
15200
+ * Mirrors `createAsyncDerivationStream` (value pipeline) but writes the result via
15201
+ * `store.setOverride(fieldKey, targetProperty, value)` instead of patching the form.
15202
+ *
15203
+ * @internal
15204
+ */
15205
+ function createAsyncPropertyDerivationStream(entry, formValue$, context) {
15206
+ if (isArrayPlaceholderPath(entry.fieldKey)) {
15207
+ context.logger.warn(`${LOG_PREFIX} Async property derivation for array field '${entry.fieldKey}' is not supported. ` +
15208
+ `Array async property derivations will be supported in a future release.`);
15209
+ return EMPTY;
15210
+ }
15211
+ if (!entry.asyncFunctionName && !entry.asyncFn) {
15212
+ context.logger.warn(`${LOG_PREFIX} Property derivation for '${entry.fieldKey}.${entry.targetProperty}' has neither "asyncFunctionName" nor "asyncFn". ` +
15213
+ `One of the two is required for source: 'asyncFunction'.`);
15214
+ return EMPTY;
15215
+ }
15216
+ if (entry.asyncFn && entry.asyncFunctionName) {
15217
+ context.logger.warn(`${LOG_PREFIX} Both "asyncFn" and "asyncFunctionName" are set on property derivation for '${entry.fieldKey}.${entry.targetProperty}'. ` +
15218
+ `Inline "asyncFn" takes precedence; "asyncFunctionName" is ignored.`);
15219
+ }
15220
+ const inlineAsyncFn = entry.asyncFn;
15221
+ const asyncFunctionName = entry.asyncFunctionName;
15222
+ const debounceMs = entry.debounceMs ?? DEFAULT_ASYNC_DEBOUNCE_MS;
15223
+ return formValue$.pipe(startWith(null), pairwise(), map(([previous, current]) => ({
15224
+ current: current,
15225
+ changedFields: getChangedKeys(previous, current),
15226
+ })), filter(({ changedFields }) => {
15227
+ if (changedFields.size === 0)
15228
+ return false;
15229
+ return entry.dependsOn.some((dep) => changedFields.has(dep));
15230
+ }), debounceTime(debounceMs), switchMap(({ current }) => {
15231
+ return new Observable((subscriber) => {
15232
+ const customFunctions = context.customFunctions?.();
15233
+ const asyncDerivationFunctions = context.asyncDerivationFunctions?.();
15234
+ const externalData = context.externalData?.();
15235
+ const asyncFn = inlineAsyncFn ?? (asyncFunctionName ? asyncDerivationFunctions?.[asyncFunctionName] : undefined);
15236
+ if (!asyncFn) {
15237
+ context.logger.warn(`${LOG_PREFIX} Async derivation function '${asyncFunctionName}' not found for '${entry.fieldKey}.${entry.targetProperty}'. ` +
15238
+ `Register it in customFnConfig.asyncDerivations.`);
15239
+ subscriber.complete();
15240
+ return;
15241
+ }
15242
+ const fieldValue = getNestedValue(current, entry.fieldKey);
15243
+ const evalContext = {
15244
+ fieldValue,
15245
+ formValue: current,
15246
+ fieldPath: entry.fieldKey,
15247
+ customFunctions,
15248
+ externalData,
15249
+ logger: context.logger,
15250
+ };
15251
+ if (entry.condition !== true) {
15252
+ const conditionMet = typeof entry.condition === 'boolean' ? entry.condition : evaluateCondition(entry.condition, evalContext);
15253
+ if (!conditionMet) {
15254
+ subscriber.complete();
15255
+ return;
15256
+ }
15257
+ }
15258
+ const result$ = from(asyncFn(evalContext));
15259
+ const asyncSub = result$.subscribe({
15260
+ next: (newValue) => {
15261
+ try {
15262
+ untracked(() => context.store.setOverride(entry.fieldKey, entry.targetProperty, newValue));
15263
+ subscriber.complete();
15264
+ }
15265
+ catch (error) {
15266
+ const message = error instanceof Error ? error.message : String(error);
15267
+ context.logger.warn(`${LOG_PREFIX} Failed to process result for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15268
+ subscriber.complete();
15269
+ }
15270
+ },
15271
+ error: (error) => {
15272
+ const message = error instanceof Error ? error.message : String(error);
15273
+ context.logger.warn(`${LOG_PREFIX} Async function failed for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15274
+ subscriber.complete();
15275
+ },
15276
+ complete: () => {
15277
+ subscriber.complete();
15278
+ },
15279
+ });
15280
+ return () => {
15281
+ asyncSub.unsubscribe();
15282
+ };
15283
+ });
15284
+ }), catchError$1((error) => {
15285
+ const message = error instanceof Error ? error.message : String(error);
15286
+ context.logger.warn(`${LOG_PREFIX} Unexpected stream error for '${entry.fieldKey}.${entry.targetProperty}': ${message}`);
15287
+ return EMPTY;
15288
+ }));
15289
+ }
15290
+
14024
15291
  /**
14025
15292
  * Orchestrates property derivation processing for a dynamic form.
14026
15293
  *
@@ -14040,6 +15307,7 @@ class PropertyDerivationOrchestrator {
14040
15307
  deprecationTracker = inject(DEPRECATION_WARNING_TRACKER);
14041
15308
  functionRegistry = inject(FunctionRegistryService);
14042
15309
  formOptions = inject(FORM_OPTIONS);
15310
+ httpClient = inject(HttpClient, { optional: true });
14043
15311
  /**
14044
15312
  * Computed signal containing the collected property derivations.
14045
15313
  * Returns null if no property derivations are defined.
@@ -14075,6 +15343,8 @@ class PropertyDerivationOrchestrator {
14075
15343
  });
14076
15344
  this.setupOnChangeStream();
14077
15345
  this.setupDebouncedStream();
15346
+ this.setupHttpStreams();
15347
+ this.setupAsyncFunctionStreams();
14078
15348
  }
14079
15349
  setupOnChangeStream() {
14080
15350
  const collection$ = toObservable(this.propertyDerivationCollection, { injector: this.injector });
@@ -14144,40 +15414,161 @@ class PropertyDerivationOrchestrator {
14144
15414
  };
14145
15415
  applyPropertyDerivationsForTrigger(filteredCollection, 'debounced', applicatorContext, changedFields);
14146
15416
  }
14147
- }
14148
- /**
14149
- * Creates a PropertyDerivationOrchestrator for a dynamic form.
14150
- * Must be called within an injection context.
14151
- *
14152
- * @param config - Form-specific signals configuration
14153
- * @returns The created PropertyDerivationOrchestrator
14154
- *
14155
- * @public
14156
- */
14157
- function createPropertyDerivationOrchestrator(config) {
14158
- return new PropertyDerivationOrchestrator(config);
14159
- }
14160
- /**
14161
- * Injection token for the PropertyDerivationOrchestrator.
14162
- *
14163
- * @public
14164
- */
14165
- const PROPERTY_DERIVATION_ORCHESTRATOR = new InjectionToken('PROPERTY_DERIVATION_ORCHESTRATOR');
14166
- // ─────────────────────────────────────────────────────────────────────────────
14167
- // Dev-mode diagnostics (tree-shaken in prod via DEV_MODE gate at call site)
14168
- // ─────────────────────────────────────────────────────────────────────────────
14169
- function warnAboutWildcardDependencies(logger, entries, fieldCount) {
14170
- const implicitWildcards = entries.filter((entry) => entry.dependsOn.includes('*') &&
14171
- entry.functionName &&
14172
- (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
14173
- if (implicitWildcards.length > 0) {
14174
- const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName})`);
14175
- logger.warn('PropertyDerivation: custom functions without explicit dependsOn detected. ' +
14176
- `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
15417
+ /**
15418
+ * Declarative pipeline for HTTP property-derivation entries. Delegates the
15419
+ * collection-watching / dedup / cancellation plumbing to
15420
+ * {@link buildEntryStreamPipeline} so both orchestrators share one
15421
+ * implementation.
15422
+ *
15423
+ * Errors inside `createStream` invocations are isolated via the inner
15424
+ * stream's own `catchError`; we additionally wrap the outer pipeline so a
15425
+ * synchronous throw during stream construction (e.g., evaluation of a
15426
+ * `condition`) doesn't tear the whole pipeline down for the lifetime of
15427
+ * the form.
15428
+ */
15429
+ setupHttpStreams() {
15430
+ const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
15431
+ const collection$ = toObservable(this.propertyDerivationCollection, { injector: this.injector });
15432
+ buildEntryStreamPipeline({
15433
+ collection$,
15434
+ selectEntries: (collection) => collection?.entries.filter((entry) => entry.http) ?? [],
15435
+ entrySignature: httpEntrySignature,
15436
+ createStream: (entry) => {
15437
+ if (!this.httpClient) {
15438
+ this.logger.error('HTTP Property Derivation: HttpClient is not available. Ensure provideHttpClient() is included in your application providers.');
15439
+ return null;
15440
+ }
15441
+ return createHttpPropertyDerivationStream(entry, formValue$, {
15442
+ formValue: this.config.formValue,
15443
+ store: this.config.store,
15444
+ httpClient: this.httpClient,
15445
+ logger: this.logger,
15446
+ customFunctions: () => this.functionRegistry.getCustomFunctions(),
15447
+ externalData: () => resolveExternalData(this.config.externalData),
15448
+ });
15449
+ },
15450
+ })
15451
+ .pipe(catchError((err) => {
15452
+ this.logger.error('HTTP Property Derivation: outer stream error', err);
15453
+ return EMPTY;
15454
+ }), takeUntilDestroyed(this.destroyRef))
15455
+ .subscribe();
15456
+ }
15457
+ setupAsyncFunctionStreams() {
15458
+ const formValue$ = toObservable(this.config.formValue, { injector: this.injector });
15459
+ const collection$ = toObservable(this.propertyDerivationCollection, { injector: this.injector });
15460
+ buildEntryStreamPipeline({
15461
+ collection$,
15462
+ selectEntries: (collection) => collection?.entries.filter((entry) => entry.asyncFunctionName || entry.asyncFn) ?? [],
15463
+ entrySignature: asyncEntrySignature,
15464
+ createStream: (entry) => createAsyncPropertyDerivationStream(entry, formValue$, {
15465
+ formValue: this.config.formValue,
15466
+ store: this.config.store,
15467
+ logger: this.logger,
15468
+ customFunctions: () => this.functionRegistry.getCustomFunctions(),
15469
+ asyncDerivationFunctions: () => this.functionRegistry.getAsyncDerivationFunctions(),
15470
+ externalData: () => resolveExternalData(this.config.externalData),
15471
+ }),
15472
+ })
15473
+ .pipe(catchError((err) => {
15474
+ this.logger.error('Async Property Derivation: outer stream error', err);
15475
+ return EMPTY;
15476
+ }), takeUntilDestroyed(this.destroyRef))
15477
+ .subscribe();
15478
+ }
15479
+ }
15480
+ /**
15481
+ * Identity signature for an HTTP property-derivation entry. Includes every
15482
+ * field that drives the inner stream's behavior — changing any of them must
15483
+ * tear down and rebuild the stream. Stable across topological reorderings
15484
+ * because the consumer ({@link entrySetsEqual}) compares as a multiset.
15485
+ */
15486
+ function httpEntrySignature(entry) {
15487
+ const config = {
15488
+ http: entry.http,
15489
+ responseExpression: entry.responseExpression,
15490
+ dependsOn: entry.dependsOn,
15491
+ debounceMs: entry.debounceMs,
15492
+ trigger: entry.trigger,
15493
+ condition: entry.condition,
15494
+ };
15495
+ return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;
15496
+ }
15497
+ /**
15498
+ * Identity signature for an async-function property-derivation entry. Same
15499
+ * principle as {@link httpEntrySignature}: include every field the inner
15500
+ * stream closes over so changes force a rebuild. Inline `asyncFn`s use the
15501
+ * `fieldKey.targetProperty` path as a stable identifier — function identity
15502
+ * isn't JSON-serializable.
15503
+ */
15504
+ function asyncEntrySignature(entry) {
15505
+ const config = {
15506
+ asyncFunctionName: entry.asyncFunctionName,
15507
+ asyncFnId: entry.asyncFn ? `inline:${entry.fieldKey}.${entry.targetProperty}` : undefined,
15508
+ dependsOn: entry.dependsOn,
15509
+ debounceMs: entry.debounceMs,
15510
+ trigger: entry.trigger,
15511
+ condition: entry.condition,
15512
+ };
15513
+ return `${entry.fieldKey}.${entry.targetProperty}:${JSON.stringify(config)}`;
15514
+ }
15515
+ /**
15516
+ * Creates a PropertyDerivationOrchestrator for a dynamic form.
15517
+ * Must be called within an injection context.
15518
+ *
15519
+ * @param config - Form-specific signals configuration
15520
+ * @returns The created PropertyDerivationOrchestrator
15521
+ *
15522
+ * @public
15523
+ */
15524
+ function createPropertyDerivationOrchestrator(config) {
15525
+ return new PropertyDerivationOrchestrator(config);
15526
+ }
15527
+ /**
15528
+ * Injection token for the PropertyDerivationOrchestrator.
15529
+ *
15530
+ * @public
15531
+ */
15532
+ const PROPERTY_DERIVATION_ORCHESTRATOR = new InjectionToken('PROPERTY_DERIVATION_ORCHESTRATOR');
15533
+ // ─────────────────────────────────────────────────────────────────────────────
15534
+ // Dev-mode diagnostics (tree-shaken in prod via DEV_MODE gate at call site)
15535
+ // ─────────────────────────────────────────────────────────────────────────────
15536
+ function warnAboutWildcardDependencies(logger, entries, fieldCount) {
15537
+ const implicitWildcards = entries.filter((entry) => entry.dependsOn.includes('*') &&
15538
+ (entry.functionName || entry.fn) &&
15539
+ (!entry.originalConfig?.dependsOn || entry.originalConfig.dependsOn.length === 0));
15540
+ if (implicitWildcards.length > 0) {
15541
+ const derivationDescs = implicitWildcards.map((e) => `${e.fieldKey}.${e.targetProperty} (${e.functionName ?? '<inline fn>'})`);
15542
+ logger.warn('PropertyDerivation: custom functions without explicit dependsOn detected. ' +
15543
+ `These run on EVERY form change, which may impact performance (form has ${fieldCount} fields). ` +
14177
15544
  'Consider specifying explicit dependsOn arrays for better performance.', derivationDescs);
14178
15545
  }
14179
15546
  }
14180
15547
 
15548
+ /**
15549
+ * Multi-provider token populated by `provideAddonActions({...})` calls.
15550
+ * `ADDON_ACTION_REGISTRY` aggregates these into a single name → handler map.
15551
+ *
15552
+ * @internal
15553
+ */
15554
+ const ADDON_ACTION_HANDLERS = new InjectionToken('ADDON_ACTION_HANDLERS');
15555
+ /**
15556
+ * Form-scoped name → handler map. Read by adapter button kinds at click
15557
+ * time for `actionRef: 'name'` lookups. Provided at form scope (not root)
15558
+ * because `ADDON_ACTION_HANDLERS` is form-scoped via `provideAddonActions`.
15559
+ */
15560
+ const ADDON_ACTION_REGISTRY = new InjectionToken('ADDON_ACTION_REGISTRY');
15561
+ function createAddonActionRegistry() {
15562
+ const sources = inject(ADDON_ACTION_HANDLERS, { optional: true }) ?? [];
15563
+ const map = new Map();
15564
+ for (const handlers of sources) {
15565
+ for (const [name, fn] of Object.entries(handlers)) {
15566
+ map.set(name, fn);
15567
+ }
15568
+ }
15569
+ return map;
15570
+ }
15571
+
14181
15572
  /**
14182
15573
  * Always-on providers for any DynamicForm: state machine, registries, signal-context tokens.
14183
15574
  * Pure form bootstrap — no derivation, HTTP, or async machinery.
@@ -14192,7 +15583,7 @@ function coreProviders() {
14192
15583
  FunctionRegistryService,
14193
15584
  {
14194
15585
  provide: FORM_STATE_DEPS,
14195
- useFactory: () => ({ config: null, formOptions: null, value: null }),
15586
+ useFactory: () => ({ config: null, formOptions: null, value: null, source: null }),
14196
15587
  },
14197
15588
  FormStateManager,
14198
15589
  {
@@ -14201,6 +15592,7 @@ function coreProviders() {
14201
15592
  deps: [FormStateManager],
14202
15593
  },
14203
15594
  FieldContextRegistryService,
15595
+ ArrayItemRegistryService,
14204
15596
  {
14205
15597
  provide: DEFAULT_PROPS,
14206
15598
  useFactory: (stateManager) => computed(() => stateManager.activeConfig()?.defaultProps),
@@ -14227,6 +15619,11 @@ function coreProviders() {
14227
15619
  deps: [FormStateManager],
14228
15620
  },
14229
15621
  { provide: DEPRECATION_WARNING_TRACKER, useFactory: createWarningTracker },
15622
+ // ADDON_ACTION_REGISTRY must live at form scope (not root) because the
15623
+ // multi-provider source `ADDON_ACTION_HANDLERS` is contributed by
15624
+ // `provideAddonActions(...)` features passed to `provideDynamicForm(...)`
15625
+ // — those entries are only visible inside the form's own injector tree.
15626
+ { provide: ADDON_ACTION_REGISTRY, useFactory: createAddonActionRegistry },
14230
15627
  ];
14231
15628
  }
14232
15629
  /**
@@ -14418,13 +15815,90 @@ class EventDispatcher {
14418
15815
  disconnect() {
14419
15816
  this.bus = undefined;
14420
15817
  }
14421
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
14422
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher });
15818
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
15819
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher });
15820
+ }
15821
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EventDispatcher, decorators: [{
15822
+ type: Injectable
15823
+ }] });
15824
+
15825
+ /**
15826
+ * Marks an `<ng-template>` projected into `<df-dynamic-form>` content as
15827
+ * available to template addons by name.
15828
+ *
15829
+ * The host form collects all `DfTemplate` instances via `contentChildren`
15830
+ * and exposes them through {@link DF_FIELD_TEMPLATES}, where the template
15831
+ * addon kind looks them up by `templateKey` at render time.
15832
+ *
15833
+ * @example
15834
+ * ```html
15835
+ * <df-dynamic-form [config]="config">
15836
+ * <ng-template dfTemplate="searchIcon" let-field>
15837
+ * <my-icon [field]="field" />
15838
+ * </ng-template>
15839
+ * </df-dynamic-form>
15840
+ * ```
15841
+ *
15842
+ * In the field config:
15843
+ * ```ts
15844
+ * { slot: 'prefix', kind: 'template', templateKey: 'searchIcon' }
15845
+ * ```
15846
+ */
15847
+ class DfTemplate {
15848
+ /** Name this template registers under — referenced from `TemplateAddon.templateKey`. */
15849
+ dfTemplate = input.required(...(ngDevMode ? [{ debugName: "dfTemplate" }] : /* istanbul ignore next */ []));
15850
+ /** The wrapped template, captured for registration and `NgTemplateOutlet` rendering. */
15851
+ templateRef = inject(TemplateRef);
15852
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
15853
+ 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 });
14423
15854
  }
14424
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: EventDispatcher, decorators: [{
15855
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfTemplate, decorators: [{
15856
+ type: Directive,
15857
+ args: [{
15858
+ selector: 'ng-template[dfTemplate]',
15859
+ exportAs: 'dfTemplate',
15860
+ }]
15861
+ }], propDecorators: { dfTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "dfTemplate", required: true }] }] } });
15862
+ /**
15863
+ * Holder service published at the `<df-dynamic-form>` component scope.
15864
+ * `DynamicForm` collects projected `<ng-template dfTemplate="...">` via
15865
+ * `contentChildren(DfTemplate)` and pushes the resulting map through
15866
+ * `DfFieldTemplateRegistry.set(...)`. The {@link DF_FIELD_TEMPLATES}
15867
+ * injection token is provided as a factory that returns this service's
15868
+ * `map` signal, so template-addon components can resolve `templateKey`
15869
+ * without coupling to the host form's generic class.
15870
+ */
15871
+ class DfFieldTemplateRegistry {
15872
+ _map = signal(new Map(), ...(ngDevMode ? [{ debugName: "_map" }] : /* istanbul ignore next */ []));
15873
+ /** Reactive view consumed by the `template` addon kind via DI. */
15874
+ map = this._map.asReadonly();
15875
+ /** Replace the registry contents — called from `DynamicForm` when projected templates change. */
15876
+ set(value) {
15877
+ this._map.set(value);
15878
+ }
15879
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
15880
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry });
15881
+ }
15882
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfFieldTemplateRegistry, decorators: [{
14425
15883
  type: Injectable
14426
15884
  }] });
14427
15885
 
15886
+ /**
15887
+ * Map of named `TemplateRef`s collected by `<df-dynamic-form>` from
15888
+ * `<ng-template dfTemplate="...">` children projected into its content.
15889
+ * The directive selector is `dfTemplate` (see `DfTemplate`); the input
15890
+ * value is the name addons reference via `templateKey`.
15891
+ *
15892
+ * The `template` addon kind looks up its `templateKey` in this map at
15893
+ * render time. Exposed as a `Signal` so projections can be added or
15894
+ * removed reactively (e.g., when the host conditionally projects).
15895
+ *
15896
+ * Provided at the form-component level (not root) so multiple
15897
+ * `<df-dynamic-form>` instances on the same page have independent
15898
+ * template scopes.
15899
+ */
15900
+ const DF_FIELD_TEMPLATES = new InjectionToken('DF_FIELD_TEMPLATES');
15901
+
14428
15902
  /**
14429
15903
  * Dynamic form component — renders a form based on configuration.
14430
15904
  * Delegates state management to `FormStateManager`.
@@ -14444,6 +15918,13 @@ class DynamicForm {
14444
15918
  formOptions = input(undefined, ...(ngDevMode ? [{ debugName: "formOptions" }] : /* istanbul ignore next */ []));
14445
15919
  /** Form values for two-way data binding. */
14446
15920
  value = model(undefined, ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
15921
+ /**
15922
+ * How lenient the addon validator is on `config.fields[...].addons`.
15923
+ * `'inline'` (default) keeps everything; `'json'` drops code-only kinds
15924
+ * with a warning. Read at form init and on every config swap — change
15925
+ * `source` AND the config to force re-validation mid-flight.
15926
+ */
15927
+ source = input('inline', ...(ngDevMode ? [{ debugName: "source" }] : /* istanbul ignore next */ []));
14447
15928
  // ─────────────────────────────────────────────────────────────────────────────
14448
15929
  // Dependencies
14449
15930
  // ─────────────────────────────────────────────────────────────────────────────
@@ -14453,6 +15934,12 @@ class DynamicForm {
14453
15934
  eventBus = inject(EventBus);
14454
15935
  logger = inject(DynamicFormLogger);
14455
15936
  dispatcher = inject(EventDispatcher, { optional: true });
15937
+ templateRegistry = inject(DfFieldTemplateRegistry);
15938
+ // ─────────────────────────────────────────────────────────────────────────────
15939
+ // Projected templates — collected for the `template` addon kind via DI.
15940
+ // ─────────────────────────────────────────────────────────────────────────────
15941
+ /** All `<ng-template dfTemplate="...">` instances projected into this form. */
15942
+ _projectedTemplates = contentChildren(DfTemplate, ...(ngDevMode ? [{ debugName: "_projectedTemplates" }] : /* istanbul ignore next */ []));
14456
15943
  /** State manager that owns all form state. Initialized via connectDeps() to guarantee
14457
15944
  * FORM_STATE_DEPS is populated before FormStateManager is injected. */
14458
15945
  stateManager = this.connectDeps();
@@ -14605,6 +16092,15 @@ class DynamicForm {
14605
16092
  }, { injector: this.injector });
14606
16093
  }
14607
16094
  });
16095
+ // Mirror projected `<ng-template dfTemplate="...">` into the template
16096
+ // registry so `template` addons can resolve `templateKey` via DI.
16097
+ explicitEffect([this._projectedTemplates], ([templates]) => {
16098
+ const map = new Map();
16099
+ for (const t of templates) {
16100
+ map.set(t.dfTemplate(), t.templateRef);
16101
+ }
16102
+ this.templateRegistry.set(map);
16103
+ });
14608
16104
  }
14609
16105
  /**
14610
16106
  * Populates FORM_STATE_DEPS with this component's input signals, then injects
@@ -14617,6 +16113,7 @@ class DynamicForm {
14617
16113
  deps.config = this.config;
14618
16114
  deps.formOptions = this.formOptions;
14619
16115
  deps.value = this.value;
16116
+ deps.source = this.source;
14620
16117
  return inject((FormStateManager));
14621
16118
  }
14622
16119
  setupEventHandlers() {
@@ -14632,8 +16129,12 @@ class DynamicForm {
14632
16129
  error: (err) => this.logger.error('Submission handler error', err),
14633
16130
  });
14634
16131
  }
14635
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
14636
- 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: `
16132
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicForm, deps: [], target: i0.ɵɵFactoryTarget.Component });
16133
+ 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: [
16134
+ provideDynamicFormDI(),
16135
+ DfFieldTemplateRegistry,
16136
+ { provide: DF_FIELD_TEMPLATES, useFactory: () => inject(DfFieldTemplateRegistry).map },
16137
+ ], queries: [{ propertyName: "_projectedTemplates", predicate: DfTemplate, isSignal: true }], ngImport: i0, template: `
14637
16138
  @if (shouldRender()) {
14638
16139
  @switch (formModeDetection().mode) {
14639
16140
  @case ('paged') {
@@ -14641,7 +16142,13 @@ class DynamicForm {
14641
16142
  }
14642
16143
  @case ('non-paged') {
14643
16144
  @for (field of resolvedFields(); track field.key) {
14644
- <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
16145
+ <!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
16146
+ the template @if removes the host from the DOM (Angular's prescribed pattern), and
16147
+ the directive's gate stops a same-CD-pass mount before [formField] can warn.
16148
+ Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
16149
+ @if (!field.hidden()) {
16150
+ <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
16151
+ }
14645
16152
  }
14646
16153
  }
14647
16154
  @default {
@@ -14649,9 +16156,9 @@ class DynamicForm {
14649
16156
  }
14650
16157
  }
14651
16158
  }
14652
- `, 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 });
16159
+ `, 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 });
14653
16160
  }
14654
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicForm, decorators: [{
16161
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicForm, decorators: [{
14655
16162
  type: Component,
14656
16163
  args: [{ selector: 'form[dynamic-form]', imports: [DfFieldOutlet, PageOrchestratorComponent], template: `
14657
16164
  @if (shouldRender()) {
@@ -14661,7 +16168,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14661
16168
  }
14662
16169
  @case ('non-paged') {
14663
16170
  @for (field of resolvedFields(); track field.key) {
14664
- <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
16171
+ <!-- @if + DfFieldOutlet's own renderReady-&-!hidden gate together silence NG01916:
16172
+ the template @if removes the host from the DOM (Angular's prescribed pattern), and
16173
+ the directive's gate stops a same-CD-pass mount before [formField] can warn.
16174
+ Don't dedupe one without the other — see DfFieldOutlet.renderReady. -->
16175
+ @if (!field.hidden()) {
16176
+ <ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
16177
+ }
14665
16178
  }
14666
16179
  }
14667
16180
  @default {
@@ -14669,7 +16182,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14669
16182
  }
14670
16183
  }
14671
16184
  }
14672
- `, providers: [provideDynamicFormDI()], host: {
16185
+ `, providers: [
16186
+ provideDynamicFormDI(),
16187
+ DfFieldTemplateRegistry,
16188
+ { provide: DF_FIELD_TEMPLATES, useFactory: () => inject(DfFieldTemplateRegistry).map },
16189
+ ], host: {
14673
16190
  class: 'df-dynamic-form df-form',
14674
16191
  novalidate: '', // Disable browser validation - Angular Signal Forms handles validation
14675
16192
  '[class.disabled]': 'disabled()',
@@ -14677,8 +16194,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImpor
14677
16194
  '[class.df-form-non-paged]': 'formModeDetection().mode === "non-paged"',
14678
16195
  '[attr.data-form-mode]': 'formModeDetection().mode',
14679
16196
  '(submit)': 'onNativeSubmit($event)',
14680
- }, 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"] }]
14681
- }], 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"] }] } });
16197
+ }, 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"] }]
16198
+ }], 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"] }] } });
14682
16199
  /**
14683
16200
  * Recursively counts container fields (page, row, group, array, wrapper) in a field tree.
14684
16201
  * Descends into container children including array item templates to ensure
@@ -14708,6 +16225,147 @@ function countContainersRecursive(fields) {
14708
16225
  return count;
14709
16226
  }
14710
16227
 
16228
+ /**
16229
+ * Cache for resolved addon-kind components.
16230
+ *
16231
+ * Provided per-form by `provideDynamicFormDI()` so two `<df-dynamic-form>`
16232
+ * instances with conflicting `loadComponent` factories for the same kind
16233
+ * never share a stale entry. Has no `providedIn: 'root'` factory — the
16234
+ * registry consumer is always inside a form scope, and a root fallback
16235
+ * would only hide misconfiguration (an SSR cross-request hit if a future
16236
+ * code path forgot to provide it locally).
16237
+ *
16238
+ * Mirrors `COMPONENT_CACHE` (field types) and `WRAPPER_COMPONENT_CACHE`
16239
+ * (wrappers).
16240
+ *
16241
+ * @internal
16242
+ */
16243
+ const ADDON_KIND_COMPONENT_CACHE = new InjectionToken('ADDON_KIND_COMPONENT_CACHE');
16244
+ /**
16245
+ * Injection function for accessing the addon kind registry.
16246
+ *
16247
+ * Usage parallels {@link injectFieldRegistry}: lookup, lazy load, cache.
16248
+ * Must be called within an injection context.
16249
+ *
16250
+ * @example
16251
+ * ```typescript
16252
+ * const addonKinds = injectAddonKindRegistry();
16253
+ * const Component = await addonKinds.loadKindComponent('prime-icon');
16254
+ * ```
16255
+ */
16256
+ function injectAddonKindRegistry() {
16257
+ const registry = inject(ADDON_KIND_REGISTRY);
16258
+ const componentCache = inject(ADDON_KIND_COMPONENT_CACHE);
16259
+ return {
16260
+ /** Look up a kind definition by its discriminant. */
16261
+ getKind(kind) {
16262
+ return registry.get(kind);
16263
+ },
16264
+ /** Whether a kind is currently registered. */
16265
+ hasKind(kind) {
16266
+ return registry.has(kind);
16267
+ },
16268
+ /** All registered kind names — useful for actionable validator warnings. */
16269
+ getKindNames() {
16270
+ return Array.from(registry.keys());
16271
+ },
16272
+ /**
16273
+ * Resolve a kind to its renderer component. Caches on first resolution.
16274
+ *
16275
+ * @throws DynamicFormError when the kind is not registered.
16276
+ */
16277
+ async loadKindComponent(kind) {
16278
+ const definition = registry.get(kind);
16279
+ if (!definition) {
16280
+ const known = Array.from(registry.keys()).join(', ') || '(none)';
16281
+ throw new DynamicFormError(`Addon kind '${kind}' is not registered. Currently registered kinds: ${known}.`);
16282
+ }
16283
+ const cached = componentCache.get(kind);
16284
+ if (cached) {
16285
+ return cached;
16286
+ }
16287
+ try {
16288
+ const component = resolveDefaultExport(await definition.loadComponent());
16289
+ if (component) {
16290
+ componentCache.set(kind, component);
16291
+ return component;
16292
+ }
16293
+ throw new DynamicFormError(`Addon kind '${kind}' loadComponent resolved to null.`);
16294
+ }
16295
+ catch (error) {
16296
+ if (error instanceof DynamicFormError)
16297
+ throw error;
16298
+ throw new DynamicFormError(`Failed to load component for addon kind '${kind}': ${error instanceof Error ? error.message : String(error)}`);
16299
+ }
16300
+ },
16301
+ /**
16302
+ * Synchronous lookup of an already-loaded kind component, or undefined.
16303
+ * Returns `undefined` when the kind isn't registered in this scope even
16304
+ * if a prior scope cached a component under the same name — prevents
16305
+ * cross-form cache hits when registrations diverge.
16306
+ */
16307
+ getLoadedKindComponent(kind) {
16308
+ if (!registry.has(kind))
16309
+ return undefined;
16310
+ return componentCache.get(kind);
16311
+ },
16312
+ /** Direct read-only access to the registry map. */
16313
+ get raw() {
16314
+ return registry;
16315
+ },
16316
+ };
16317
+ }
16318
+
16319
+ /**
16320
+ * Built-in addon kinds shipped by core.
16321
+ *
16322
+ * Universally adapter-independent: `text`, `template`, `component`. Adapters
16323
+ * register their own kinds (e.g., `prime-icon`, `prime-button`) via
16324
+ * `withCustomAddon(...)` features.
16325
+ */
16326
+ const BUILT_IN_ADDON_KINDS = [
16327
+ {
16328
+ kind: 'text',
16329
+ loadComponent: () => Promise.resolve().then(function () { return textAddon_component; }).then((m) => m.TextAddonComponent),
16330
+ validate: (addon, fieldKey) => {
16331
+ const text = addon.text;
16332
+ // Validates structural presence: rejects missing / null / non-string-non-callable,
16333
+ // and rejects literal empty strings. DynamicText values that resolve to empty
16334
+ // at runtime (signal/observable yielding '') are NOT caught here — they're
16335
+ // legal as far as the config is concerned, just render an empty span.
16336
+ if (text === undefined || text === null) {
16337
+ throw new DynamicFormError(`Addon kind 'text' requires a 'text' field (field: '${fieldKey}').`);
16338
+ }
16339
+ if (typeof text === 'string' && text.length === 0) {
16340
+ throw new DynamicFormError(`Addon kind 'text' requires a non-empty 'text' string (field: '${fieldKey}').`);
16341
+ }
16342
+ },
16343
+ },
16344
+ {
16345
+ kind: 'template',
16346
+ loadComponent: () => Promise.resolve().then(function () { return templateAddon_component; }).then((m) => m.TemplateAddonComponent),
16347
+ validate: (addon, fieldKey) => {
16348
+ const key = addon.templateKey;
16349
+ if (typeof key !== 'string' || key.length === 0) {
16350
+ throw new DynamicFormError(`Addon kind 'template' requires a non-empty 'templateKey' string (field: '${fieldKey}').`);
16351
+ }
16352
+ },
16353
+ },
16354
+ {
16355
+ kind: 'component',
16356
+ // The shape carries a function loader — not serialisable; dropped by the
16357
+ // validator when the form config originated from JSON.
16358
+ jsonSafe: false,
16359
+ loadComponent: () => Promise.resolve().then(function () { return componentAddon_component; }).then((m) => m.ComponentAddonComponent),
16360
+ validate: (addon, fieldKey) => {
16361
+ const loader = addon.component;
16362
+ if (typeof loader !== 'function') {
16363
+ throw new DynamicFormError(`Addon kind 'component' requires a 'component' loader function (field: '${fieldKey}').`);
16364
+ }
16365
+ },
16366
+ },
16367
+ ];
16368
+
14711
16369
  /**
14712
16370
  * No-op logger for when no logger is provided.
14713
16371
  * Used as fallback in button logic evaluation to avoid breaking downstream packages.
@@ -15244,8 +16902,14 @@ function containerFieldMapper(fieldDef) {
15244
16902
  * ```
15245
16903
  */
15246
16904
  /**
15247
- * Base field type definitions for fields that don't use the `field` input.
15248
- * Used by display-only fields like `text` that render immediately without
16905
+ * Base for container field types that consume the mapper-emitted `field` input
16906
+ * (row, group, array, page, container).
16907
+ */
16908
+ const CONTAINER_FIELD_TYPES_BASE = {
16909
+ renderReadyWhen: ['field'],
16910
+ };
16911
+ /**
16912
+ * Base for display-only fields (`text`) that render immediately without
15249
16913
  * waiting for form value integration.
15250
16914
  */
15251
16915
  const DISPLAY_FIELD_TYPES_BASE = {
@@ -15264,31 +16928,35 @@ const BUILT_IN_FIELDS = [
15264
16928
  // `row` is a virtual field type: it maps to ContainerFieldComponent, and
15265
16929
  // the rowFieldMapper injects a `{ type: 'row' }` wrapper so the container
15266
16930
  // renders the flex/grid layout via RowWrapperComponent.
15267
- loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-BCIQ6_Mm.mjs'),
16931
+ loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs'),
15268
16932
  mapper: rowFieldMapper,
15269
16933
  valueHandling: 'flatten',
16934
+ ...CONTAINER_FIELD_TYPES_BASE,
15270
16935
  },
15271
16936
  {
15272
16937
  name: 'group',
15273
- loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-DKcoco0G.mjs'),
16938
+ loadComponent: () => import('./ng-forge-dynamic-forms-group-field.component-BJ8j7Sh0.mjs'),
15274
16939
  mapper: groupFieldMapper,
15275
16940
  valueHandling: 'include',
16941
+ ...CONTAINER_FIELD_TYPES_BASE,
15276
16942
  },
15277
16943
  {
15278
16944
  name: 'array',
15279
- loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-us7mQtVQ.mjs'),
16945
+ loadComponent: () => import('./ng-forge-dynamic-forms-array-field.component-GiaVHO9N.mjs'),
15280
16946
  mapper: arrayFieldMapper,
15281
16947
  valueHandling: 'include',
16948
+ ...CONTAINER_FIELD_TYPES_BASE,
15282
16949
  },
15283
16950
  {
15284
16951
  name: 'page',
15285
- loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-DqWAcwhG.mjs'),
16952
+ loadComponent: () => import('./ng-forge-dynamic-forms-page-field.component-Bz2RpleR.mjs'),
15286
16953
  mapper: pageFieldMapper,
15287
16954
  valueHandling: 'flatten',
16955
+ ...CONTAINER_FIELD_TYPES_BASE,
15288
16956
  },
15289
16957
  {
15290
16958
  name: 'text',
15291
- loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-8C1-oyU_.mjs'),
16959
+ loadComponent: () => import('./ng-forge-dynamic-forms-text-field.component-BGw6T-4h.mjs'),
15292
16960
  mapper: textFieldMapper,
15293
16961
  valueHandling: 'exclude',
15294
16962
  ...DISPLAY_FIELD_TYPES_BASE,
@@ -15300,22 +16968,32 @@ const BUILT_IN_FIELDS = [
15300
16968
  },
15301
16969
  {
15302
16970
  name: 'container',
15303
- loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-BCIQ6_Mm.mjs'),
16971
+ loadComponent: () => import('./ng-forge-dynamic-forms-container-field.component-C554hOxN.mjs'),
15304
16972
  mapper: containerFieldMapper,
15305
16973
  valueHandling: 'flatten',
16974
+ ...CONTAINER_FIELD_TYPES_BASE,
15306
16975
  },
15307
16976
  ];
15308
16977
  const BUILT_IN_WRAPPERS = [
15309
16978
  {
15310
16979
  wrapperName: 'css',
15311
- loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-Dhlf6UxN.mjs'),
16980
+ loadComponent: () => import('./ng-forge-dynamic-forms-css-wrapper.component-CnJgFWLB.mjs'),
15312
16981
  },
15313
16982
  {
15314
16983
  wrapperName: 'row',
15315
- loadComponent: () => import('./ng-forge-dynamic-forms-row-wrapper.component-BIvEUoia.mjs'),
16984
+ loadComponent: () => import('./ng-forge-dynamic-forms-row-wrapper.component-CFtnatYj.mjs'),
15316
16985
  },
15317
16986
  ];
15318
16987
 
16988
+ /**
16989
+ * Multi-provider token used by `withCustomAddon(...)` features to contribute
16990
+ * addon kind definitions. `provideDynamicForm` aggregates all values into
16991
+ * the form-scoped `ADDON_KIND_REGISTRY` map.
16992
+ *
16993
+ * @internal
16994
+ */
16995
+ const ADDON_KIND_DEFINITIONS = new InjectionToken('ADDON_KIND_DEFINITIONS');
16996
+
15319
16997
  /**
15320
16998
  * Bundle wrapper registrations into a single object that can be passed to
15321
16999
  * `provideDynamicForm(...)`.
@@ -15432,8 +17110,6 @@ function provideDynamicForm(...items) {
15432
17110
  const defaultLoggerProvider = hasLoggerFeature ? [] : [{ provide: DynamicFormLogger, useValue: new ConsoleLogger() }];
15433
17111
  return makeEnvironmentProviders([
15434
17112
  ...defaultLoggerProvider,
15435
- // Always provide default Signal Forms classes (ng-touched, ng-invalid, etc.)
15436
- provideSignalFormsConfig({ classes: NG_STATUS_CLASSES }),
15437
17113
  {
15438
17114
  provide: FIELD_REGISTRY,
15439
17115
  useFactory: () => {
@@ -15484,6 +17160,31 @@ function provideDynamicForm(...items) {
15484
17160
  return autoMap;
15485
17161
  },
15486
17162
  },
17163
+ // Form-scoped addon kind registry. Built from BUILT_IN_ADDON_KINDS plus
17164
+ // every kind contributed by `withCustomAddon(...)` features (via the
17165
+ // ADDON_KIND_DEFINITIONS multi-provider). Overrides the empty
17166
+ // `providedIn: 'root'` default so contributions from form-scoped
17167
+ // providers are visible.
17168
+ {
17169
+ provide: ADDON_KIND_REGISTRY,
17170
+ useFactory: () => {
17171
+ const logger = inject(DynamicFormLogger);
17172
+ const contributed = inject(ADDON_KIND_DEFINITIONS, { optional: true }) ?? [];
17173
+ const registry = new Map();
17174
+ for (const def of BUILT_IN_ADDON_KINDS) {
17175
+ registry.set(def.kind, def);
17176
+ }
17177
+ for (const def of contributed) {
17178
+ if (registry.has(def.kind)) {
17179
+ logger.warn(`Addon kind "${def.kind}" is already registered. Overwriting.`);
17180
+ }
17181
+ registry.set(def.kind, def);
17182
+ }
17183
+ return registry;
17184
+ },
17185
+ },
17186
+ // Per-form addon-component cache (no root fallback — isolated by form scope).
17187
+ { provide: ADDON_KIND_COMPONENT_CACHE, useFactory: () => new Map() },
15487
17188
  ...featureProviders,
15488
17189
  ]);
15489
17190
  }
@@ -15660,6 +17361,35 @@ function withValidationExecutionDefaults(config) {
15660
17361
  return createFeature('validation-execution', [{ provide: VALIDATION_EXECUTION_DEFAULTS, useValue: resolved }]);
15661
17362
  }
15662
17363
 
17364
+ /**
17365
+ * Opt into Angular Signal Forms' legacy `.ng-touched` / `.ng-invalid` /
17366
+ * `.ng-dirty` / `.ng-pristine` / `.ng-untouched` / `.ng-pending` /
17367
+ * `.ng-valid` CSS classes on form-bound elements.
17368
+ *
17369
+ * Wires Angular's `@angular/forms/signals/compat` `NG_STATUS_CLASSES` strategy
17370
+ * via `provideSignalFormsConfig`. Without this feature the modern Signal Forms
17371
+ * class strategy is active, which omits the legacy `ng-*` classes.
17372
+ *
17373
+ * @example Opt in once at the application root
17374
+ * ```typescript
17375
+ * bootstrapApplication(AppComponent, {
17376
+ * providers: [
17377
+ * provideDynamicForm(
17378
+ * ...withMaterialFields(),
17379
+ * withLegacyStatusClasses()
17380
+ * )
17381
+ * ]
17382
+ * });
17383
+ * ```
17384
+ *
17385
+ * @returns A DynamicFormFeature that enables the legacy `ng-*` class strategy
17386
+ *
17387
+ * @public
17388
+ */
17389
+ function withLegacyStatusClasses() {
17390
+ return createFeature('legacy-status-classes', [...provideSignalFormsConfig({ classes: NG_STATUS_CLASSES })]);
17391
+ }
17392
+
15663
17393
  /**
15664
17394
  * Zero-cost type carrier used in wrapper registrations.
15665
17395
  *
@@ -15687,6 +17417,713 @@ function wrapperProps() {
15687
17417
  return undefined;
15688
17418
  }
15689
17419
 
17420
+ /** Type guard narrowing {@link AddonActionContext} to its field-bound variant. */
17421
+ function isFieldBoundContext(ctx) {
17422
+ return ctx.form !== null;
17423
+ }
17424
+
17425
+ /**
17426
+ * Normalises a {@link DynamicValue} into a `Signal<T>`.
17427
+ *
17428
+ * Accepts a static value, a `Signal<T>`, or an `Observable<T>`. The result
17429
+ * is a `Signal<T>` that reactively reflects the current value, suitable
17430
+ * for direct use in Angular templates.
17431
+ *
17432
+ * Static values yield a `computed` that always returns the same constant —
17433
+ * preferable to wrapping in `signal()` because the computed never causes
17434
+ * change-detection invalidation.
17435
+ *
17436
+ * Pass an `injector` when calling outside an Angular injection context
17437
+ * (e.g., from inside a `computed`). `toSignal` needs a `DestroyRef` to
17438
+ * unsubscribe — without an injector it falls back to `inject(DestroyRef)`,
17439
+ * which throws when called outside injection context.
17440
+ *
17441
+ * @example
17442
+ * ```typescript
17443
+ * const isHidden = resolveDynamicValue(addon().hidden, false);
17444
+ * // template: \@if (isHidden()) { ... }
17445
+ * ```
17446
+ */
17447
+ function resolveDynamicValue(value, fallback, injector) {
17448
+ if (value === undefined) {
17449
+ return computed(() => fallback);
17450
+ }
17451
+ if (isSignal(value)) {
17452
+ return value;
17453
+ }
17454
+ if (isObservable(value)) {
17455
+ return toSignal(value, { initialValue: fallback, injector });
17456
+ }
17457
+ // Static value
17458
+ return computed(() => value);
17459
+ }
17460
+
17461
+ /**
17462
+ * Dispatcher component that renders an addon by looking up its `kind` in
17463
+ * `ADDON_KIND_REGISTRY` and instantiating the registered component.
17464
+ *
17465
+ * Used by every adapter's field component to render slot content:
17466
+ *
17467
+ * ```html
17468
+ * \@for (a of addonsAt('prefix'); track $index) {
17469
+ * <df-addon-slot [addon]="a" />
17470
+ * }
17471
+ * ```
17472
+ *
17473
+ * Behaviour:
17474
+ * - Resolves and caches the kind's component asynchronously on first render.
17475
+ * - Forwards the `slot` HTML attribute on the host so Ionic shadow-DOM
17476
+ * projection works (`<ion-input>` projects children with `slot="start"`
17477
+ * into its native start slot).
17478
+ * - Honours the addon's reactive `hidden` flag — when `true`, the host is
17479
+ * `display: none` so it occupies no layout but stays in DOM (cheaper than
17480
+ * tearing down the component).
17481
+ * - Forwards `className` to the host element.
17482
+ * - ARIA semantics are owned by individual kind components — decorative
17483
+ * kinds (icons, text) set `aria-hidden="true"` on themselves; interactive
17484
+ * kinds (buttons) handle their own `aria-label`.
17485
+ *
17486
+ * If the kind cannot be resolved (registry miss, load failure), the
17487
+ * dispatcher logs a warning and renders nothing — never throws. The
17488
+ * runtime addon validator should have caught unknown kinds at config init,
17489
+ * but this provides a defence-in-depth fallback for misconfigured registries.
17490
+ */
17491
+ class DfAddonSlot {
17492
+ registry = injectAddonKindRegistry();
17493
+ logger = inject(DynamicFormLogger);
17494
+ hostInjector = inject(Injector);
17495
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17496
+ /**
17497
+ * Optional read-only view of the host field's mapped inputs — same shape
17498
+ * wrappers receive (`field?: ReadonlyFieldTree`, `key`, `props`, etc.).
17499
+ * Forwarded to kind components so they can read field state without
17500
+ * re-injecting `FIELD_SIGNAL_CONTEXT` and rebuilding context themselves.
17501
+ *
17502
+ * `undefined` when the addon is rendered outside a field (rare).
17503
+ */
17504
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17505
+ /**
17506
+ * Pre-resolved `hidden` signal supplied by the host field. When provided,
17507
+ * the dispatcher reads this instead of re-resolving `addon.hidden` —
17508
+ * lets the host (e.g., `prime-input`) share a single subscription per
17509
+ * Observable-typed `hidden` between its own visibility filter and the
17510
+ * slot's `[style.display]` binding.
17511
+ */
17512
+ hidden = input(undefined, ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
17513
+ /** `slot` HTML attribute forwarded to host — needed for Ionic shadow projection. */
17514
+ slotAttr = computed(() => this.addon().slot, ...(ngDevMode ? [{ debugName: "slotAttr" }] : /* istanbul ignore next */ []));
17515
+ className = computed(() => this.addon().className ?? null, ...(ngDevMode ? [{ debugName: "className" }] : /* istanbul ignore next */ []));
17516
+ /**
17517
+ * Locally-resolved `hidden` signal — always reflects `addon.hidden`
17518
+ * regardless of whether the host supplied a pre-resolved signal.
17519
+ * Populated via `explicitEffect` so `resolveDynamicValue` (which may call
17520
+ * `toSignal` for Observable-typed values) does NOT run inside a reactive
17521
+ * context (NG0602). The WeakMap caches per `hidden`-value identity, so
17522
+ * swapping `addon` inputs that share the same Observable reuses the
17523
+ * subscription instead of leaking a fresh one per swap.
17524
+ *
17525
+ * Resolved unconditionally (not gated on host `hidden()` presence) so a
17526
+ * transition from `host hidden = Signal` → `undefined` doesn't expose a
17527
+ * brief window where neither value is current — the upstream effect
17528
+ * re-runs eventually, but the computed read can happen in between.
17529
+ */
17530
+ _hiddenByValue = new WeakMap();
17531
+ _resolvedHidden = signal(undefined, ...(ngDevMode ? [{ debugName: "_resolvedHidden" }] : /* istanbul ignore next */ []));
17532
+ /**
17533
+ * `hidden` resolved from `DynamicValue<boolean>` to a flat `Signal<boolean>`.
17534
+ * Prefers the host-supplied signal when present (avoids duplicate `toSignal`
17535
+ * subscriptions for Observable-typed `hidden`); falls back to the locally-
17536
+ * resolved signal, which is always populated by the addon-tracking effect.
17537
+ */
17538
+ isHidden = computed(() => {
17539
+ const pre = this.hidden();
17540
+ if (pre)
17541
+ return pre();
17542
+ const local = this._resolvedHidden();
17543
+ return local ? local() : false;
17544
+ }, ...(ngDevMode ? [{ debugName: "isHidden" }] : /* istanbul ignore next */ []));
17545
+ /**
17546
+ * Latest async-loaded `{ kind, component }` pair. Tagging the result with
17547
+ * its kind lets `resolvedComponent` filter stale loads structurally — no
17548
+ * monotonic token needed. When `addon.kind` changes mid-load, the prior
17549
+ * load still completes and writes here, but the computed ignores it
17550
+ * because the recorded kind no longer matches the current one.
17551
+ */
17552
+ asyncLoaded = signal(undefined, ...(ngDevMode ? [{ debugName: "asyncLoaded" }] : /* istanbul ignore next */ []));
17553
+ /**
17554
+ * Resolved kind component — composed declaratively from two sources of
17555
+ * truth: the registry's synchronous cache (warm hits, e.g., a second
17556
+ * `<df-addon-slot>` for an already-loaded kind) and the kind-tagged async
17557
+ * loader result. Reading `addon().kind` first makes the computed react to
17558
+ * addon swaps and naturally invalidate stale async-load results.
17559
+ */
17560
+ resolvedComponent = computed(() => {
17561
+ const kind = this.addon().kind;
17562
+ const cached = this.registry.getLoadedKindComponent(kind);
17563
+ if (cached)
17564
+ return cached;
17565
+ const loaded = this.asyncLoaded();
17566
+ return loaded?.kind === kind ? loaded.component : undefined;
17567
+ }, ...(ngDevMode ? [{ debugName: "resolvedComponent" }] : /* istanbul ignore next */ []));
17568
+ /** Inputs passed to the kind component — addon plus the wrapper-style host bag. */
17569
+ kindInputs = computed(() => ({
17570
+ addon: this.addon(),
17571
+ fieldInputs: this.fieldInputs(),
17572
+ }), ...(ngDevMode ? [{ debugName: "kindInputs" }] : /* istanbul ignore next */ []));
17573
+ constructor() {
17574
+ // Resolve `addon.hidden` outside any reactive context AND unconditionally —
17575
+ // the host-supplied signal is a perf hint, not a precondition. Keeping
17576
+ // `_resolvedHidden` always-fresh closes a race where `isHidden` could
17577
+ // return `false` between a host-`hidden` clear and the effect re-running.
17578
+ explicitEffect([this.addon], ([addon]) => {
17579
+ const h = addon.hidden;
17580
+ const valueKey = typeof h === 'object' && h !== null ? h : null;
17581
+ let resolved = valueKey ? this._hiddenByValue.get(valueKey) : undefined;
17582
+ if (!resolved) {
17583
+ resolved = resolveDynamicValue(addon.hidden, false, this.hostInjector);
17584
+ if (valueKey)
17585
+ this._hiddenByValue.set(valueKey, resolved);
17586
+ }
17587
+ this._resolvedHidden.set(resolved);
17588
+ });
17589
+ // Kick off async loads on cache miss. `resolvedComponent` filters stale
17590
+ // results by kind identity, so this effect is a pure side-effect dispatcher.
17591
+ explicitEffect([this.addon], ([addon]) => {
17592
+ const kind = addon.kind;
17593
+ if (this.registry.getLoadedKindComponent(kind))
17594
+ return; // sync path; computed picks it up
17595
+ this.registry
17596
+ .loadKindComponent(kind)
17597
+ .then((component) => this.asyncLoaded.set({ kind, component }))
17598
+ .catch((error) => {
17599
+ this.logger.warn(`Failed to load addon kind '${kind}': ${error instanceof Error ? error.message : String(error)}. ` +
17600
+ `Registered kinds: ${this.registry.getKindNames().join(', ') || '(none)'}.`);
17601
+ });
17602
+ });
17603
+ }
17604
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfAddonSlot, deps: [], target: i0.ɵɵFactoryTarget.Component });
17605
+ 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: `
17606
+ @if (resolvedComponent(); as cmp) {
17607
+ <ng-container *ngComponentOutlet="cmp; inputs: kindInputs()" />
17608
+ }
17609
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17610
+ }
17611
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DfAddonSlot, decorators: [{
17612
+ type: Component,
17613
+ args: [{
17614
+ selector: 'df-addon-slot',
17615
+ imports: [NgComponentOutlet],
17616
+ template: `
17617
+ @if (resolvedComponent(); as cmp) {
17618
+ <ng-container *ngComponentOutlet="cmp; inputs: kindInputs()" />
17619
+ }
17620
+ `,
17621
+ host: {
17622
+ '[attr.slot]': 'slotAttr()',
17623
+ '[class]': 'className()',
17624
+ '[style.display]': 'isHidden() ? "none" : null',
17625
+ },
17626
+ changeDetection: ChangeDetectionStrategy.OnPush,
17627
+ }]
17628
+ }], 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 }] }] } });
17629
+
17630
+ /**
17631
+ * Pipe that handles dynamic text resolution with support for static strings,
17632
+ * Observables, and Signals.
17633
+ *
17634
+ * Supports:
17635
+ * - Static strings (pass-through)
17636
+ * - Observables (subscribed internally)
17637
+ * - Signals (converted to Observable using toObservable for reactivity)
17638
+ *
17639
+ * @example
17640
+ * ```html
17641
+ * <!-- Static string -->
17642
+ * {{ 'Hello World' | dynamicText | async }}
17643
+ *
17644
+ * <!-- Observable -->
17645
+ * {{ transloco.selectTranslate('key') | dynamicText | async }}
17646
+ *
17647
+ * <!-- Signal -->
17648
+ * {{ myTextSignal | dynamicText | async }}
17649
+ * ```
17650
+ *
17651
+ * @public
17652
+ */
17653
+ class DynamicTextPipe {
17654
+ injector = inject(Injector);
17655
+ /**
17656
+ * Transforms dynamic text input into a resolved string value
17657
+ *
17658
+ * @param value - The dynamic text value to resolve
17659
+ * @returns The resolved string value as an Observable
17660
+ */
17661
+ transform(value) {
17662
+ if (isObservable(value)) {
17663
+ return value;
17664
+ }
17665
+ if (isSignal(value)) {
17666
+ return toObservable(value, { injector: this.injector });
17667
+ }
17668
+ return of(value || '');
17669
+ }
17670
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
17671
+ static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, isStandalone: true, name: "dynamicText" });
17672
+ }
17673
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: DynamicTextPipe, decorators: [{
17674
+ type: Pipe,
17675
+ args: [{
17676
+ name: 'dynamicText',
17677
+ }]
17678
+ }] });
17679
+
17680
+ /**
17681
+ * Renderer for the universal `text` addon kind.
17682
+ *
17683
+ * Renders the addon's `text` (a `DynamicText`) inside a span. Decorative —
17684
+ * sets `aria-hidden="true"` on the host so screen readers skip it; users
17685
+ * who need announcement should use a `*-button` kind with `ariaLabel` instead.
17686
+ */
17687
+ class TextAddonComponent {
17688
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17689
+ /** Accepted for contract uniformity — `NgComponentOutlet` setInput is strict; every kind must declare it. */
17690
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17691
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TextAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17692
+ 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 });
17693
+ }
17694
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TextAddonComponent, decorators: [{
17695
+ type: Component,
17696
+ args: [{
17697
+ selector: 'df-text-addon',
17698
+ imports: [AsyncPipe, DynamicTextPipe],
17699
+ template: `<span>{{ addon().text | dynamicText | async }}</span>`,
17700
+ host: {
17701
+ '[attr.aria-hidden]': '"true"',
17702
+ },
17703
+ changeDetection: ChangeDetectionStrategy.OnPush,
17704
+ }]
17705
+ }], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17706
+
17707
+ var textAddon_component = /*#__PURE__*/Object.freeze({
17708
+ __proto__: null,
17709
+ TextAddonComponent: TextAddonComponent
17710
+ });
17711
+
17712
+ /**
17713
+ * Renderer for the universal `template` addon kind.
17714
+ *
17715
+ * Resolves the addon's `templateKey` against {@link DF_FIELD_TEMPLATES}
17716
+ * (populated by `<df-dynamic-form>` from `<ng-template dfTemplate="...">`
17717
+ * children) and renders via `NgTemplateOutlet`. The wrapper-style host bag
17718
+ * (`fieldInputs`) is forwarded as the template's implicit context — author
17719
+ * the template as `<ng-template dfTemplate="..." let-fi>{{ fi.key }}</ng-template>`.
17720
+ *
17721
+ * If the key is unresolved, logs a warning and renders nothing — keeps the
17722
+ * form alive when a backend references a template the FE has not registered.
17723
+ */
17724
+ class TemplateAddonComponent {
17725
+ templates = inject(DF_FIELD_TEMPLATES, { optional: true });
17726
+ logger = inject(DynamicFormLogger);
17727
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17728
+ /** Forwarded by `df-addon-slot`; surfaced to the template as its implicit `$implicit` context. */
17729
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17730
+ // Pure computed: pure lookup, no side effects. Unresolved → null; the
17731
+ // `@if` in the template skips the outlet and renders nothing.
17732
+ template = computed(() => {
17733
+ const map = this.templates?.();
17734
+ const key = this.addon().templateKey;
17735
+ return map?.get(key) ?? null;
17736
+ }, ...(ngDevMode ? [{ debugName: "template" }] : /* istanbul ignore next */ []));
17737
+ /** Implicit context payload — template authors bind via `let-fi`. */
17738
+ outletContext = computed(() => ({ $implicit: this.fieldInputs() }), ...(ngDevMode ? [{ debugName: "outletContext" }] : /* istanbul ignore next */ []));
17739
+ constructor() {
17740
+ // Logging side effect lives in an effect, not the computed — keeps
17741
+ // the computed pure so signal-graph evaluations don't duplicate warnings.
17742
+ explicitEffect([this.template, this.addon], ([tpl, addon]) => {
17743
+ if (!tpl) {
17744
+ this.logger.warn(`Template addon: no <ng-template dfTemplate="${addon.templateKey}"> found. Did you project it as a child of <df-dynamic-form>?`);
17745
+ }
17746
+ });
17747
+ }
17748
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TemplateAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17749
+ 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: `
17750
+ @if (template(); as tpl) {
17751
+ <ng-container *ngTemplateOutlet="tpl; context: outletContext()" />
17752
+ }
17753
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17754
+ }
17755
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: TemplateAddonComponent, decorators: [{
17756
+ type: Component,
17757
+ args: [{
17758
+ selector: 'df-template-addon',
17759
+ imports: [NgTemplateOutlet],
17760
+ template: `
17761
+ @if (template(); as tpl) {
17762
+ <ng-container *ngTemplateOutlet="tpl; context: outletContext()" />
17763
+ }
17764
+ `,
17765
+ changeDetection: ChangeDetectionStrategy.OnPush,
17766
+ }]
17767
+ }], ctorParameters: () => [], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17768
+
17769
+ var templateAddon_component = /*#__PURE__*/Object.freeze({
17770
+ __proto__: null,
17771
+ TemplateAddonComponent: TemplateAddonComponent
17772
+ });
17773
+
17774
+ /**
17775
+ * Renderer for the universal `component` addon kind.
17776
+ *
17777
+ * Loads the user-supplied component lazily (`addon.component()`) and renders
17778
+ * it via `NgComponentOutlet` with `addon.inputs` forwarded as input bindings.
17779
+ * The wrapper-style `fieldInputs` bag is not propagated — user components
17780
+ * receive only what `addon.inputs` declares. Authors that need field state
17781
+ * should switch to the `template` kind or build a wrapper.
17782
+ *
17783
+ * @codeOnly The `component` loader is a function; this kind is dropped in
17784
+ * lenient validation when the config originated from JSON.
17785
+ */
17786
+ class ComponentAddonComponent {
17787
+ logger = inject(DynamicFormLogger);
17788
+ addon = input.required(...(ngDevMode ? [{ debugName: "addon" }] : /* istanbul ignore next */ []));
17789
+ /** Accepted for contract uniformity — `NgComponentOutlet` setInput is strict; every kind must declare it. */
17790
+ fieldInputs = input(...(ngDevMode ? [undefined, { debugName: "fieldInputs" }] : /* istanbul ignore next */ []));
17791
+ inputs = computed(() => (this.addon().inputs ?? {}), ...(ngDevMode ? [{ debugName: "inputs" }] : /* istanbul ignore next */ []));
17792
+ /**
17793
+ * Latest async-loaded `{ addon, component }` pair. Tagging the result with
17794
+ * its source addon lets `resolvedComponent` filter stale loads structurally
17795
+ * — same kind-tagging pattern `DfAddonSlot` uses. When `addon` swaps
17796
+ * mid-load, the prior loader still completes and writes here, but the
17797
+ * computed ignores it because the recorded addon no longer matches the
17798
+ * current one.
17799
+ */
17800
+ asyncLoaded = signal(undefined, ...(ngDevMode ? [{ debugName: "asyncLoaded" }] : /* istanbul ignore next */ []));
17801
+ resolvedComponent = computed(() => {
17802
+ const current = this.addon();
17803
+ const loaded = this.asyncLoaded();
17804
+ return loaded?.addon === current ? loaded.component : undefined;
17805
+ }, ...(ngDevMode ? [{ debugName: "resolvedComponent" }] : /* istanbul ignore next */ []));
17806
+ constructor() {
17807
+ explicitEffect([this.addon], ([addon]) => {
17808
+ Promise.resolve(addon.component())
17809
+ .then((mod) => {
17810
+ const component = resolveDefaultExport(mod);
17811
+ if (component)
17812
+ this.asyncLoaded.set({ addon, component });
17813
+ })
17814
+ .catch((error) => {
17815
+ this.logger.warn(`Failed to load component addon (slot: '${addon.slot}'): ${error instanceof Error ? error.message : String(error)}`);
17816
+ });
17817
+ });
17818
+ }
17819
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentAddonComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
17820
+ 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: `
17821
+ @if (resolvedComponent(); as cmp) {
17822
+ <ng-container *ngComponentOutlet="cmp; inputs: inputs()" />
17823
+ }
17824
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
17825
+ }
17826
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: ComponentAddonComponent, decorators: [{
17827
+ type: Component,
17828
+ args: [{
17829
+ selector: 'df-component-addon',
17830
+ imports: [NgComponentOutlet],
17831
+ template: `
17832
+ @if (resolvedComponent(); as cmp) {
17833
+ <ng-container *ngComponentOutlet="cmp; inputs: inputs()" />
17834
+ }
17835
+ `,
17836
+ changeDetection: ChangeDetectionStrategy.OnPush,
17837
+ }]
17838
+ }], ctorParameters: () => [], propDecorators: { addon: [{ type: i0.Input, args: [{ isSignal: true, alias: "addon", required: true }] }], fieldInputs: [{ type: i0.Input, args: [{ isSignal: true, alias: "fieldInputs", required: false }] }] } });
17839
+
17840
+ var componentAddon_component = /*#__PURE__*/Object.freeze({
17841
+ __proto__: null,
17842
+ ComponentAddonComponent: ComponentAddonComponent
17843
+ });
17844
+
17845
+ /**
17846
+ * Apply a built-in preset action against the addon's host field. Shared
17847
+ * across all adapters — each adapter's `ADDON_PRESET_HANDLER` provider
17848
+ * delegates here.
17849
+ *
17850
+ * Presets that require adapter-specific UI mechanics (clipboard, type
17851
+ * override) are best-effort — they no-op with a warning when the necessary
17852
+ * collaborator isn't available (e.g., toggle-password-visibility outside an
17853
+ * input field).
17854
+ *
17855
+ * @param adapterLabel Used in warnings — `'Material'` / `'Bootstrap'` / etc.
17856
+ * @param fieldLabel Used in warnings — `'mat-input'` / `'bs-input'` / etc.
17857
+ */
17858
+ async function runPresetAction(preset, ctx, collaborators, adapterLabel, fieldLabel) {
17859
+ // Guard against orphan dispatches — none of the built-in presets do
17860
+ // anything useful without a host field, except `copy` and `paste` which
17861
+ // only need the clipboard API. The canonical discriminant per the
17862
+ // `AddonActionContext` discriminated union is `ctx.form` — non-null on
17863
+ // the field-bound variant, null on the orphan variant. `field.key` was
17864
+ // unreliable for nested-array scenarios where keys can legitimately be
17865
+ // empty strings.
17866
+ if (ctx.form === null && preset !== 'copy' && preset !== 'paste') {
17867
+ collaborators.logger.warn(`preset '${String(preset)}' fired without a host field context — ignoring.`);
17868
+ return;
17869
+ }
17870
+ switch (preset) {
17871
+ case 'clear': {
17872
+ // Preserve type semantics: strings clear to `''` (browser-friendly for
17873
+ // input.value), everything else clears to `undefined` (numeric / object
17874
+ // / date fields would otherwise become a string and break their type).
17875
+ const next = typeof ctx.value === 'string' ? '' : undefined;
17876
+ collaborators.fieldValueSetter?.(next);
17877
+ return;
17878
+ }
17879
+ case 'reset': {
17880
+ const defaultValue = collaborators.fieldDefaultValueGetter?.();
17881
+ collaborators.fieldValueSetter?.(defaultValue !== undefined ? defaultValue : typeof ctx.value === 'string' ? '' : undefined);
17882
+ return;
17883
+ }
17884
+ case 'paste': {
17885
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
17886
+ collaborators.logger.warn(`preset 'paste' requires a browser clipboard API (not available in this environment).`);
17887
+ return;
17888
+ }
17889
+ try {
17890
+ const text = await navigator.clipboard.readText();
17891
+ collaborators.fieldValueSetter?.(text);
17892
+ }
17893
+ catch (error) {
17894
+ collaborators.logger.warn(`preset 'paste' clipboard read failed: ${error instanceof Error ? error.message : String(error)}`);
17895
+ }
17896
+ return;
17897
+ }
17898
+ case 'copy': {
17899
+ if (typeof navigator === 'undefined' || !navigator.clipboard) {
17900
+ collaborators.logger.warn(`preset 'copy' requires a browser clipboard API (not available in this environment).`);
17901
+ return;
17902
+ }
17903
+ try {
17904
+ // Explicit null check — `String(null) === 'null'` would write the
17905
+ // literal string "null" to the clipboard.
17906
+ const v = ctx.value;
17907
+ await navigator.clipboard.writeText(v == null ? '' : String(v));
17908
+ }
17909
+ catch (error) {
17910
+ collaborators.logger.warn(`preset 'copy' clipboard write failed: ${error instanceof Error ? error.message : String(error)}`);
17911
+ }
17912
+ return;
17913
+ }
17914
+ case 'toggle-password-visibility': {
17915
+ const override = collaborators.typeOverride;
17916
+ if (!override) {
17917
+ collaborators.logger.warn(`preset 'toggle-password-visibility' has no effect outside a '${fieldLabel}' field.`);
17918
+ return;
17919
+ }
17920
+ // Refuse to flip non-password inputs. Re-check on EVERY dispatch (not
17921
+ // just when override is undefined) because `props().type` can change
17922
+ // reactively after the first toggle — e.g., 'password' → 'email'. Once
17923
+ // the override is touched, only the override is canonical for the
17924
+ // current visible/hidden state; the baseline still gates whether
17925
+ // toggling makes semantic sense for this field.
17926
+ const baseline = collaborators.baselineType?.();
17927
+ if (baseline !== undefined && baseline !== 'password') {
17928
+ collaborators.logger.warn(`preset 'toggle-password-visibility' refused: host '${fieldLabel}' has type '${baseline}', not 'password'.`);
17929
+ return;
17930
+ }
17931
+ const current = override();
17932
+ override.set(current === 'text' ? 'password' : 'text');
17933
+ return;
17934
+ }
17935
+ default:
17936
+ collaborators.logger.warn(`preset '${String(preset)}' is not implemented in the ${adapterLabel} adapter.`);
17937
+ }
17938
+ }
17939
+
17940
+ /**
17941
+ * Walk `FIELD_REGISTRY` and return every field type that opted into addons
17942
+ * (i.e., declared `addons` on its `FieldTypeDefinition`).
17943
+ *
17944
+ * Useful for tooling — admin UIs surfacing "which fields can carry addons?",
17945
+ * docs generators, MCP server cross-checks. Field types without an `addons`
17946
+ * registration ("Tier 3" — toggle, checkbox, radio, slider) are excluded.
17947
+ *
17948
+ * Must be called within an injection context.
17949
+ *
17950
+ * @example
17951
+ * ```typescript
17952
+ * @Component({ ... })
17953
+ * class FormBuilder {
17954
+ * protected readonly addonable = injectFieldsSupportingAddons();
17955
+ *
17956
+ * constructor() {
17957
+ * // -> [{ name: 'prime-input', slots: ['prefix', 'suffix'], allowedKinds: [...] }, ...]
17958
+ * }
17959
+ * }
17960
+ * ```
17961
+ */
17962
+ function injectFieldsSupportingAddons() {
17963
+ const registry = inject(FIELD_REGISTRY);
17964
+ const entries = [];
17965
+ for (const [name, def] of registry.entries()) {
17966
+ if (def.addons === undefined)
17967
+ continue;
17968
+ entries.push({ name, slots: def.addons.slots, allowedKinds: def.addons.allowedKinds });
17969
+ }
17970
+ return entries;
17971
+ }
17972
+
17973
+ /**
17974
+ * Register a custom addon kind with the form.
17975
+ *
17976
+ * The provided definition is added to `ADDON_KIND_REGISTRY` at form
17977
+ * initialisation, alongside the built-in `text` / `template` / `component`
17978
+ * kinds and any kinds contributed by adapter feature helpers
17979
+ * (`withPrimeNGAddons()` etc.).
17980
+ *
17981
+ * @example
17982
+ * ```typescript
17983
+ * provideDynamicForm(
17984
+ * ...withMaterialFields(),
17985
+ * withCustomAddon({
17986
+ * kind: 'lucide-icon',
17987
+ * // Match how field types register: a default-exported component lets
17988
+ * // `loadComponent` return the import promise directly.
17989
+ * loadComponent: () => import('./lucide-icon-addon.component'),
17990
+ * validate: (addon) => {
17991
+ * if (typeof addon.icon !== 'string') {
17992
+ * throw new Error('lucide-icon: icon string is required');
17993
+ * }
17994
+ * },
17995
+ * }),
17996
+ * );
17997
+ * ```
17998
+ *
17999
+ * Multiple `withCustomAddon(...)` calls are supported; later registrations
18000
+ * for the same kind override earlier ones (with a warning logged).
18001
+ */
18002
+ function withCustomAddon(definition) {
18003
+ return createFeature('addons', [
18004
+ {
18005
+ provide: ADDON_KIND_DEFINITIONS,
18006
+ useValue: definition,
18007
+ multi: true,
18008
+ },
18009
+ ]);
18010
+ }
18011
+
18012
+ /**
18013
+ * Register named action handlers reachable via JSON-driven addon configs.
18014
+ *
18015
+ * Once registered, a backend can ship
18016
+ * `{ kind: 'prime-button', actionRef: 'openProfile' }` and the FE will invoke
18017
+ * the corresponding handler at click time.
18018
+ *
18019
+ * Define handlers once and augment the registry from the same object:
18020
+ *
18021
+ * ```typescript
18022
+ * export const appActions = provideAddonActions({
18023
+ * openProfile: (ctx) => modalService.open(ProfileModal, { data: ctx.value }),
18024
+ * runWorkflow: (ctx) => workflowService.run(ctx.field.key, ctx.value),
18025
+ * });
18026
+ *
18027
+ * declare module '@ng-forge/dynamic-forms' {
18028
+ * // Pulls the keys straight off `appActions` — no manual list to keep in sync.
18029
+ * interface DynamicFormActionRegistry extends Record<NonNullable<typeof appActions['__handlerKeys']>, true> {}
18030
+ * }
18031
+ *
18032
+ * // Then in your bootstrap:
18033
+ * provideDynamicForm(...withPrimeNGFields(), appActions);
18034
+ * ```
18035
+ *
18036
+ * Multiple `provideAddonActions(...)` calls are merged; later names override
18037
+ * earlier ones for the same key.
18038
+ */
18039
+ function provideAddonActions(handlers) {
18040
+ return createFeature('addon-actions', [
18041
+ {
18042
+ provide: ADDON_ACTION_HANDLERS,
18043
+ useValue: handlers,
18044
+ multi: true,
18045
+ },
18046
+ ]);
18047
+ }
18048
+
18049
+ /**
18050
+ * Walk a `FormConfig`, validate every field's addons against the active
18051
+ * `FIELD_REGISTRY` and `ADDON_KIND_REGISTRY`, and return a sanitized copy
18052
+ * plus a list of dropped-addon warnings.
18053
+ *
18054
+ * Distinct from the INTERNAL `validateFormConfig` in
18055
+ * `utils/config-validation/config-validator.ts` (which throws on
18056
+ * structural errors like duplicate keys / unknown field types). This
18057
+ * helper is the PUBLIC, lenient addon-shape pass — renamed to
18058
+ * `sanitizeFormConfig` to avoid the name collision.
18059
+ *
18060
+ * Lenient by design: invalid addons are dropped (with warnings) rather than
18061
+ * thrown. The form keeps rendering even when the backend ships an addon the
18062
+ * FE doesn't understand.
18063
+ *
18064
+ * `<df-dynamic-form>` already runs this internally at form init — calling
18065
+ * the public helper AND passing the original config to the form will
18066
+ * double-emit warnings. Use this entry point only when you need the
18067
+ * sanitized config or warning list outside the form (e.g., admin-UI
18068
+ * diagnostics, build-time lints).
18069
+ *
18070
+ * Must be called within an Angular injection context — typically inside
18071
+ * an admin-UI component or a `runInInjectionContext()` block on the server.
18072
+ *
18073
+ * @example
18074
+ * ```typescript
18075
+ * @Component({ ... })
18076
+ * class AdminFormBuilder {
18077
+ * private readonly check = (cfg: FormConfig) => {
18078
+ * const { sanitized, warnings } = sanitizeFormConfig(cfg, { source: 'json' });
18079
+ * if (warnings.length > 0) {
18080
+ * this.diagnostics.set(warnings);
18081
+ * }
18082
+ * return sanitized;
18083
+ * };
18084
+ * }
18085
+ * ```
18086
+ */
18087
+ function sanitizeFormConfig(config, options = {}) {
18088
+ const fieldRegistry = inject(FIELD_REGISTRY);
18089
+ const kindRegistry = inject(ADDON_KIND_REGISTRY);
18090
+ const source = options.source ?? 'inline';
18091
+ const { fields, warnings } = walkAndValidateAddons((config.fields ?? []), fieldRegistry, kindRegistry, source);
18092
+ return {
18093
+ sanitized: { ...config, fields: fields },
18094
+ warnings,
18095
+ };
18096
+ }
18097
+ /**
18098
+ * Default warning sink. Pass a `DynamicFormLogger` to route through the
18099
+ * form's configured logger (respects `NoopLogger` opt-out); omit it to
18100
+ * fall back to `console.warn` for build-time / admin-UI use cases that
18101
+ * run outside an Angular injection context.
18102
+ */
18103
+ function logAddonWarnings(warnings, logger) {
18104
+ for (const w of warnings) {
18105
+ if (logger) {
18106
+ logger.warn(formatAddonWarning(w));
18107
+ }
18108
+ else {
18109
+ console.warn('[Dynamic Forms]', formatAddonWarning(w));
18110
+ }
18111
+ }
18112
+ }
18113
+ /**
18114
+ * DI-free variant of {@link sanitizeFormConfig} for tooling that lives
18115
+ * outside Angular's injector — build-time linters, MCP-side checks, CLI
18116
+ * scripts.
18117
+ *
18118
+ * Caller passes the registries explicitly; the result has the same shape
18119
+ * as the DI-bound version.
18120
+ */
18121
+ function sanitizeFormConfigPure(config, fieldRegistry, kindRegistry, options = {}) {
18122
+ const source = options.source ?? 'inline';
18123
+ const { fields, warnings } = walkAndValidateAddons((config.fields ?? []), fieldRegistry, kindRegistry, source);
18124
+ return { sanitized: { ...config, fields: fields }, warnings };
18125
+ }
18126
+
15690
18127
  function isValueField(field) {
15691
18128
  // `nullable: true` without an explicit `value` is still a value-bearing declaration:
15692
18129
  // the field resolves to `null` at runtime. Treating it as a value field keeps
@@ -15695,7 +18132,7 @@ function isValueField(field) {
15695
18132
  }
15696
18133
 
15697
18134
  function isCheckedField(field) {
15698
- return field.type === 'checkbox';
18135
+ return field.type === 'checkbox' || field.type === 'toggle';
15699
18136
  }
15700
18137
 
15701
18138
  /**
@@ -16201,56 +18638,6 @@ function applyMetaToElement(element, meta, previouslyApplied) {
16201
18638
  return newApplied;
16202
18639
  }
16203
18640
 
16204
- /**
16205
- * Pipe that handles dynamic text resolution with support for static strings,
16206
- * Observables, and Signals.
16207
- *
16208
- * Supports:
16209
- * - Static strings (pass-through)
16210
- * - Observables (subscribed internally)
16211
- * - Signals (converted to Observable using toObservable for reactivity)
16212
- *
16213
- * @example
16214
- * ```html
16215
- * <!-- Static string -->
16216
- * {{ 'Hello World' | dynamicText | async }}
16217
- *
16218
- * <!-- Observable -->
16219
- * {{ transloco.selectTranslate('key') | dynamicText | async }}
16220
- *
16221
- * <!-- Signal -->
16222
- * {{ myTextSignal | dynamicText | async }}
16223
- * ```
16224
- *
16225
- * @public
16226
- */
16227
- class DynamicTextPipe {
16228
- injector = inject(Injector);
16229
- /**
16230
- * Transforms dynamic text input into a resolved string value
16231
- *
16232
- * @param value - The dynamic text value to resolve
16233
- * @returns The resolved string value as an Observable
16234
- */
16235
- transform(value) {
16236
- if (isObservable(value)) {
16237
- return value;
16238
- }
16239
- if (isSignal(value)) {
16240
- return toObservable(value, { injector: this.injector });
16241
- }
16242
- return of(value || '');
16243
- }
16244
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
16245
- static ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, isStandalone: true, name: "dynamicText" });
16246
- }
16247
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: DynamicTextPipe, decorators: [{
16248
- type: Pipe,
16249
- args: [{
16250
- name: 'dynamicText',
16251
- }]
16252
- }] });
16253
-
16254
18641
  /**
16255
18642
  * Interpolates {{param}} placeholders in message with error values
16256
18643
  *
@@ -16382,5 +18769,5 @@ function extractErrorParams(error) {
16382
18769
  * Generated bundle index. Do not edit.
16383
18770
  */
16384
18771
 
16385
- 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 };
16386
- //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms-CfbmSRM_.mjs.map
18772
+ 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 };
18773
+ //# sourceMappingURL=ng-forge-dynamic-forms-ng-forge-dynamic-forms--PuL0AKc.mjs.map