@ng-forge/dynamic-forms 0.8.0-next.3 → 0.8.0-next.4
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.
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-CJ_OclbR.mjs +1005 -0
- package/fesm2022/ng-forge-dynamic-forms-array-field.component-CJ_OclbR.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-DQ_OQLtx.mjs +92 -0
- package/fesm2022/ng-forge-dynamic-forms-container-field.component-DQ_OQLtx.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-container-utils-zQSTe7Jk.mjs +49 -0
- package/fesm2022/ng-forge-dynamic-forms-container-utils-zQSTe7Jk.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-CGNGJoqv.mjs → ng-forge-dynamic-forms-css-wrapper.component-2fdmbtL7.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-css-wrapper.component-CGNGJoqv.mjs.map → ng-forge-dynamic-forms-css-wrapper.component-2fdmbtL7.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-pCNwBRRm.mjs +29 -0
- package/fesm2022/ng-forge-dynamic-forms-emit-initialization-pCNwBRRm.mjs.map +1 -0
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-BdLdSEHj.mjs +186 -0
- package/fesm2022/ng-forge-dynamic-forms-group-field.component-BdLdSEHj.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-ng-forge-dynamic-forms-BwoONrnj.mjs → ng-forge-dynamic-forms-ng-forge-dynamic-forms-BdMxsqmk.mjs} +12330 -13667
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-BdMxsqmk.mjs.map +1 -0
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-DV-EX9U0.mjs → ng-forge-dynamic-forms-page-field.component-C5rUo80O.mjs} +3 -2
- package/fesm2022/{ng-forge-dynamic-forms-page-field.component-DV-EX9U0.mjs.map → ng-forge-dynamic-forms-page-field.component-C5rUo80O.mjs.map} +1 -1
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-CHC8v87r.mjs → ng-forge-dynamic-forms-text-field.component-lvuyTM1r.mjs} +2 -2
- package/fesm2022/{ng-forge-dynamic-forms-text-field.component-CHC8v87r.mjs.map → ng-forge-dynamic-forms-text-field.component-lvuyTM1r.mjs.map} +1 -1
- package/fesm2022/ng-forge-dynamic-forms.mjs +1 -1
- package/package.json +1 -1
- package/types/ng-forge-dynamic-forms.d.ts +7 -8
- package/fesm2022/ng-forge-dynamic-forms-ng-forge-dynamic-forms-BwoONrnj.mjs.map +0 -1
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { computed, runInInjectionContext, Injector, linkedSignal, inject, DestroyRef, EnvironmentInjector, input, signal, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
3
|
+
import { R as RootFormRegistryService, g as isEqual, m as mapFieldToInputs, F as FIELD_SIGNAL_CONTEXT, A as ARRAY_CONTEXT, j as createRenderReadySignal, D as DynamicFormLogger, i as injectFieldRegistry, E as EventBus, k as ARRAY_TEMPLATE_REGISTRY, l as ARRAY_ITEM_ID_GENERATOR, n as getNormalizedArrayMetadata, o as getFieldValueHandling, p as getFieldDefaultValue, q as isGroupField, f as DfFieldOutlet, s as createArrayItemIdGenerator } from './ng-forge-dynamic-forms-ng-forge-dynamic-forms-BdMxsqmk.mjs';
|
|
4
|
+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
5
|
+
import { of, from, map, catchError, forkJoin, filter, firstValueFrom, tap } from 'rxjs';
|
|
6
|
+
import { explicitEffect } from 'ngxtension/explicit-effect';
|
|
7
|
+
import { e as emitComponentInitialized } from './ng-forge-dynamic-forms-emit-initialization-pCNwBRRm.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safely extracts an array value from a model object.
|
|
11
|
+
* Returns empty array if the key doesn't exist or value isn't an array.
|
|
12
|
+
*/
|
|
13
|
+
function getArrayValue(value, key) {
|
|
14
|
+
const arrayValue = value?.[key];
|
|
15
|
+
return Array.isArray(arrayValue) ? arrayValue : [];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Determines the optimal differential update operation based on current and new state.
|
|
19
|
+
*
|
|
20
|
+
* Operations:
|
|
21
|
+
* - Clear all (empty array)
|
|
22
|
+
* - Initial render (no existing items)
|
|
23
|
+
* - Append only (items added at end)
|
|
24
|
+
* - Recreate (items removed - we can't know which items, so recreate all)
|
|
25
|
+
* - None (same length - items update via linkedSignal)
|
|
26
|
+
*
|
|
27
|
+
* Note: We always use 'recreate' for removals because we can't determine which
|
|
28
|
+
* specific items were removed. Operations like shift, removeAtIndex remove from
|
|
29
|
+
* the middle, not just the end. Each array item has its own local form that
|
|
30
|
+
* doesn't reactively track parent changes, so we must recreate to get correct values.
|
|
31
|
+
*/
|
|
32
|
+
function determineDifferentialOperation(currentItems, newLength) {
|
|
33
|
+
const currentLength = currentItems.length;
|
|
34
|
+
if (newLength === 0) {
|
|
35
|
+
return { type: 'clear' };
|
|
36
|
+
}
|
|
37
|
+
if (currentLength === 0) {
|
|
38
|
+
return { type: 'initial', fieldTreesLength: newLength };
|
|
39
|
+
}
|
|
40
|
+
// For all length changes, we optimize by assuming existing items stay in place.
|
|
41
|
+
// Items handle their own value updates via linkedSignal - no need to compare snapshots.
|
|
42
|
+
// Snapshot comparison was causing false "recreate" when existing items had been edited.
|
|
43
|
+
if (newLength > currentLength) {
|
|
44
|
+
// Items added - append new ones, existing items stay
|
|
45
|
+
return { type: 'append', startIndex: currentLength, endIndex: newLength };
|
|
46
|
+
}
|
|
47
|
+
if (newLength < currentLength) {
|
|
48
|
+
// Items removed - must recreate because we don't know which items were removed.
|
|
49
|
+
// shift() removes from index 0, removeAtIndex() removes from any position.
|
|
50
|
+
// Each item's local form is initialized with its value at creation time and
|
|
51
|
+
// doesn't reactively track parent array changes.
|
|
52
|
+
return { type: 'recreate' };
|
|
53
|
+
}
|
|
54
|
+
// Same length - no structural change, items update via linkedSignal
|
|
55
|
+
return { type: 'none' };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates an injector and inputs for an array item.
|
|
60
|
+
*
|
|
61
|
+
* Uses direct root form binding - components bind directly to the root form's
|
|
62
|
+
* FieldTree for array items (rootForm['arrayKey'][index]). This architecture:
|
|
63
|
+
*
|
|
64
|
+
* - Eliminates the need for local forms and bidirectional sync
|
|
65
|
+
* - Allows Zod/StandardSchema validation errors to flow naturally to components
|
|
66
|
+
* - Reduces complexity by having a single source of truth (the root form)
|
|
67
|
+
*
|
|
68
|
+
* The FieldSignalContext.form property uses a getter that evaluates a computed
|
|
69
|
+
* signal, making form access reactive to index changes (when items reorder,
|
|
70
|
+
* the component points to the correct array position).
|
|
71
|
+
*/
|
|
72
|
+
function createArrayItemInjectorAndInputs(options) {
|
|
73
|
+
const { template, indexSignal, parentFieldSignalContext, parentInjector, registry, arrayField, primitiveFieldKey } = options;
|
|
74
|
+
// Get root form registry - it's guaranteed to be available since root form
|
|
75
|
+
// is registered before resolvedFields computes (dependency chain ensures this)
|
|
76
|
+
const rootFormRegistry = parentInjector.get(RootFormRegistryService);
|
|
77
|
+
// Create a computed that derives the array item's FieldTree from root form.
|
|
78
|
+
// Uses isEqual to prevent unnecessary re-computation when index changes but value is same.
|
|
79
|
+
const itemFormAccessor = computed(() => {
|
|
80
|
+
const rootForm = rootFormRegistry.rootForm();
|
|
81
|
+
if (!rootForm) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const index = indexSignal();
|
|
85
|
+
// Navigate: rootForm['arrayKey'][index]
|
|
86
|
+
const arrayFieldTree = rootForm[arrayField.key];
|
|
87
|
+
if (!arrayFieldTree) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
return arrayFieldTree[index];
|
|
91
|
+
}, { ...(ngDevMode ? { debugName: "itemFormAccessor" } : /* istanbul ignore next */ {}), equal: isEqual });
|
|
92
|
+
const injector = createItemInjector({
|
|
93
|
+
itemFormAccessor,
|
|
94
|
+
indexSignal,
|
|
95
|
+
parentFieldSignalContext,
|
|
96
|
+
parentInjector,
|
|
97
|
+
arrayField,
|
|
98
|
+
primitiveFieldKey,
|
|
99
|
+
});
|
|
100
|
+
// mapFieldToInputs automatically reads ARRAY_CONTEXT from the injector
|
|
101
|
+
// and applies the index suffix to keys for unique DOM IDs
|
|
102
|
+
const inputs = runInInjectionContext(injector, () => {
|
|
103
|
+
return mapFieldToInputs(template, registry);
|
|
104
|
+
});
|
|
105
|
+
return { injector, inputs };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Creates a scoped injector for an array item.
|
|
109
|
+
* Provides both FIELD_SIGNAL_CONTEXT (for form access) and ARRAY_CONTEXT (for position awareness).
|
|
110
|
+
*
|
|
111
|
+
* The FIELD_SIGNAL_CONTEXT.form uses a getter that evaluates itemFormAccessor() on access.
|
|
112
|
+
* This makes form access reactive - when mappers access context.form['fieldKey'], the getter runs,
|
|
113
|
+
* evaluating the computed which tracks indexSignal as a dependency. This ensures components
|
|
114
|
+
* automatically update when array items reorder.
|
|
115
|
+
*
|
|
116
|
+
* Uses `useFactory` with `deps: [Injector]` to provide FIELD_SIGNAL_CONTEXT. Angular resolves
|
|
117
|
+
* the `Injector` dep as the child injector being created, eliminating any temporal gap.
|
|
118
|
+
*/
|
|
119
|
+
function createItemInjector(options) {
|
|
120
|
+
const { itemFormAccessor, indexSignal, parentFieldSignalContext, parentInjector, arrayField, primitiveFieldKey } = options;
|
|
121
|
+
// Use getter for formValue to ensure it's always current, not a stale snapshot.
|
|
122
|
+
// Components accessing arrayContext.formValue will get the current value.
|
|
123
|
+
const arrayContext = {
|
|
124
|
+
arrayKey: arrayField.key,
|
|
125
|
+
index: indexSignal,
|
|
126
|
+
get formValue() {
|
|
127
|
+
return parentFieldSignalContext.value();
|
|
128
|
+
},
|
|
129
|
+
field: arrayField,
|
|
130
|
+
};
|
|
131
|
+
// Cache for the primitive wrapper object — avoids creating a new { [primitiveFieldKey]: raw }
|
|
132
|
+
// on every form getter access, which would defeat reference-equality checks downstream.
|
|
133
|
+
let cachedPrimitiveRaw;
|
|
134
|
+
let cachedPrimitiveWrapped;
|
|
135
|
+
return Injector.create({
|
|
136
|
+
parent: parentInjector,
|
|
137
|
+
providers: [
|
|
138
|
+
{
|
|
139
|
+
provide: FIELD_SIGNAL_CONTEXT,
|
|
140
|
+
// Angular resolves `Injector` in deps as the child injector being created,
|
|
141
|
+
// so no temporal gap exists — the injector reference is immediately available.
|
|
142
|
+
useFactory: (injector) => ({
|
|
143
|
+
injector,
|
|
144
|
+
value: parentFieldSignalContext.value,
|
|
145
|
+
// Array items don't propagate parent default values because each item's defaults
|
|
146
|
+
// are determined by its template fields at creation time (via getFieldDefaultValue).
|
|
147
|
+
// The parent's defaultValues contain top-level form defaults, not per-item defaults.
|
|
148
|
+
defaultValues: () => ({}),
|
|
149
|
+
get form() {
|
|
150
|
+
const raw = itemFormAccessor();
|
|
151
|
+
if (!raw) {
|
|
152
|
+
// During initialization or transitions, the FieldTree may not be available yet.
|
|
153
|
+
// This occurs in the window between item creation and Angular's form tree construction.
|
|
154
|
+
// Return an empty object so that downstream getFieldTree(key) calls return undefined
|
|
155
|
+
// rather than causing a runtime error on a truly undefined value.
|
|
156
|
+
return {};
|
|
157
|
+
}
|
|
158
|
+
// For primitive array items, the FieldTree is a FormControl (the item IS the control).
|
|
159
|
+
// Wrap it in an object so getFieldTree(primitiveFieldKey) returns the FormControl itself.
|
|
160
|
+
// Without this, getFieldTree('value') would access FormControl['value'] (the WritableSignal),
|
|
161
|
+
// not a child FieldTree, causing NG0950 errors.
|
|
162
|
+
if (primitiveFieldKey) {
|
|
163
|
+
// Cache the wrapper to preserve reference identity when raw hasn't changed
|
|
164
|
+
if (raw !== cachedPrimitiveRaw) {
|
|
165
|
+
cachedPrimitiveRaw = raw;
|
|
166
|
+
cachedPrimitiveWrapped = { [primitiveFieldKey]: raw };
|
|
167
|
+
}
|
|
168
|
+
return cachedPrimitiveWrapped;
|
|
169
|
+
}
|
|
170
|
+
return raw;
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
deps: [Injector],
|
|
174
|
+
},
|
|
175
|
+
{ provide: ARRAY_CONTEXT, useValue: arrayContext },
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Resolves a single array item with all its fields for declarative rendering.
|
|
182
|
+
*
|
|
183
|
+
* Uses linkedSignal for the index, which automatically updates when itemOrderSignal changes.
|
|
184
|
+
* This enables position-aware updates without recreating components when items are reordered.
|
|
185
|
+
* Supports multiple sibling templates (e.g., name + email without a wrapper).
|
|
186
|
+
*/
|
|
187
|
+
function resolveArrayItem(options) {
|
|
188
|
+
const { index, templates, arrayField, itemPositionMap, parentFieldSignalContext, parentInjector, registry, destroyRef, loadTypeComponent, generateItemId, primitiveFieldKey, } = options;
|
|
189
|
+
if (templates.length === 0) {
|
|
190
|
+
return of(undefined);
|
|
191
|
+
}
|
|
192
|
+
// Generate ONE id for this array item (shared by all fields for tracking)
|
|
193
|
+
const itemId = generateItemId();
|
|
194
|
+
// O(1) position lookup via Map instead of O(n) indexOf()
|
|
195
|
+
const indexSignal = linkedSignal(() => {
|
|
196
|
+
const positionMap = itemPositionMap();
|
|
197
|
+
return positionMap.get(itemId) ?? index;
|
|
198
|
+
}, ...(ngDevMode ? [{ debugName: "indexSignal" }] : /* istanbul ignore next */ []));
|
|
199
|
+
// Resolve all templates in parallel
|
|
200
|
+
const fieldObservables = templates.map((template) => from(loadTypeComponent(template.type)).pipe(map((component) => {
|
|
201
|
+
if (destroyRef.destroyed) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
// Componentless fields (e.g., hidden) return undefined - nothing to render
|
|
205
|
+
if (!component) {
|
|
206
|
+
return undefined;
|
|
207
|
+
}
|
|
208
|
+
const { injector, inputs } = createArrayItemInjectorAndInputs({
|
|
209
|
+
template,
|
|
210
|
+
indexSignal,
|
|
211
|
+
parentFieldSignalContext,
|
|
212
|
+
parentInjector,
|
|
213
|
+
registry,
|
|
214
|
+
arrayField,
|
|
215
|
+
primitiveFieldKey,
|
|
216
|
+
});
|
|
217
|
+
// Array item templates should always have inputs (componentless fields are handled above)
|
|
218
|
+
if (!inputs) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
key: template.key,
|
|
223
|
+
fieldDef: template,
|
|
224
|
+
component,
|
|
225
|
+
injector,
|
|
226
|
+
inputs,
|
|
227
|
+
renderReady: createRenderReadySignal(inputs, registry.get(template.type)),
|
|
228
|
+
};
|
|
229
|
+
}), catchError((error) => {
|
|
230
|
+
if (!destroyRef.destroyed) {
|
|
231
|
+
const logger = parentInjector.get(DynamicFormLogger);
|
|
232
|
+
logger.error(`Failed to load component for field type '${template.type}' at index ${index} ` +
|
|
233
|
+
`within array '${arrayField.key}'. Ensure the field type is registered in your field registry.`, error);
|
|
234
|
+
}
|
|
235
|
+
return of(undefined);
|
|
236
|
+
})));
|
|
237
|
+
return forkJoin(fieldObservables).pipe(map((fields) => {
|
|
238
|
+
const validFields = fields.filter((f) => f !== undefined);
|
|
239
|
+
// If no fields resolved, return undefined
|
|
240
|
+
if (validFields.length === 0) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
id: itemId,
|
|
245
|
+
fields: validFields,
|
|
246
|
+
};
|
|
247
|
+
}));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* All array event type discriminants.
|
|
252
|
+
*/
|
|
253
|
+
const ARRAY_EVENT_TYPES = [
|
|
254
|
+
'append-array-item',
|
|
255
|
+
'prepend-array-item',
|
|
256
|
+
'insert-array-item',
|
|
257
|
+
'move-array-item',
|
|
258
|
+
'pop-array-item',
|
|
259
|
+
'shift-array-item',
|
|
260
|
+
'remove-at-index',
|
|
261
|
+
];
|
|
262
|
+
/**
|
|
263
|
+
* Converts an array event to a normalized action.
|
|
264
|
+
* Uses a switch for full type safety with discriminated union narrowing.
|
|
265
|
+
*/
|
|
266
|
+
function toArrayAction(event) {
|
|
267
|
+
switch (event.type) {
|
|
268
|
+
case 'append-array-item':
|
|
269
|
+
return { action: 'add', template: event.template };
|
|
270
|
+
case 'prepend-array-item':
|
|
271
|
+
return { action: 'add', template: event.template, index: 0 };
|
|
272
|
+
case 'insert-array-item':
|
|
273
|
+
return { action: 'add', template: event.template, index: event.index };
|
|
274
|
+
case 'move-array-item':
|
|
275
|
+
return { action: 'move', fromIndex: event.fromIndex, toIndex: event.toIndex };
|
|
276
|
+
case 'pop-array-item':
|
|
277
|
+
return { action: 'remove' };
|
|
278
|
+
case 'shift-array-item':
|
|
279
|
+
return { action: 'remove', index: 0 };
|
|
280
|
+
case 'remove-at-index':
|
|
281
|
+
return { action: 'remove', index: event.index };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Creates an observable stream of normalized array actions for a specific array key.
|
|
286
|
+
*
|
|
287
|
+
* @param eventBus - The event bus to subscribe to
|
|
288
|
+
* @param arrayKey - Function returning the array key to filter events for
|
|
289
|
+
* @returns Observable of normalized ArrayAction objects
|
|
290
|
+
*
|
|
291
|
+
* @example
|
|
292
|
+
* ```typescript
|
|
293
|
+
* observeArrayActions(this.eventBus, () => this.key())
|
|
294
|
+
* .pipe(takeUntilDestroyed())
|
|
295
|
+
* .subscribe(action => {
|
|
296
|
+
* if (action.action === 'add') {
|
|
297
|
+
* this.addItem(action.template, action.index);
|
|
298
|
+
* } else {
|
|
299
|
+
* this.removeItem(action.index);
|
|
300
|
+
* }
|
|
301
|
+
* });
|
|
302
|
+
* ```
|
|
303
|
+
*/
|
|
304
|
+
function observeArrayActions(eventBus, arrayKey) {
|
|
305
|
+
return eventBus.on(ARRAY_EVENT_TYPES).pipe(filter((event) => event.arrayKey === arrayKey()), map(toArrayAction));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Container component for rendering dynamic arrays of fields.
|
|
310
|
+
*
|
|
311
|
+
* Supports add/remove/move operations via the arrayEvent() builder API.
|
|
312
|
+
* Uses differential updates to optimize rendering - only recreates items when necessary.
|
|
313
|
+
* Each item gets a scoped injector with ARRAY_CONTEXT for position-aware operations.
|
|
314
|
+
* Supports multiple sibling fields per array item (e.g., name + email without a wrapper).
|
|
315
|
+
*/
|
|
316
|
+
class ArrayFieldComponent {
|
|
317
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
+
// Dependencies
|
|
319
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
destroyRef = inject(DestroyRef);
|
|
321
|
+
fieldRegistry = injectFieldRegistry();
|
|
322
|
+
parentFieldSignalContext = inject(FIELD_SIGNAL_CONTEXT);
|
|
323
|
+
parentInjector = inject(Injector);
|
|
324
|
+
environmentInjector = inject(EnvironmentInjector);
|
|
325
|
+
eventBus = inject(EventBus);
|
|
326
|
+
logger = inject(DynamicFormLogger);
|
|
327
|
+
templateRegistry = inject(ARRAY_TEMPLATE_REGISTRY);
|
|
328
|
+
generateItemId = inject(ARRAY_ITEM_ID_GENERATOR);
|
|
329
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
330
|
+
// Inputs
|
|
331
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
332
|
+
field = input.required(...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
|
|
333
|
+
key = input.required(...(ngDevMode ? [{ debugName: "key" }] : /* istanbul ignore next */ []));
|
|
334
|
+
className = input(...(ngDevMode ? [undefined, { debugName: "className" }] : /* istanbul ignore next */ []));
|
|
335
|
+
hidden = input(false, ...(ngDevMode ? [{ debugName: "hidden" }] : /* istanbul ignore next */ []));
|
|
336
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
337
|
+
// Computed Signals
|
|
338
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
339
|
+
hostClasses = computed(() => {
|
|
340
|
+
const base = 'df-field df-array';
|
|
341
|
+
const custom = this.className();
|
|
342
|
+
return custom ? `${base} ${custom}` : base;
|
|
343
|
+
}, ...(ngDevMode ? [{ debugName: "hostClasses" }] : /* istanbul ignore next */ []));
|
|
344
|
+
rawFieldRegistry = computed(() => this.fieldRegistry.raw, ...(ngDevMode ? [{ debugName: "rawFieldRegistry" }] : /* istanbul ignore next */ []));
|
|
345
|
+
/**
|
|
346
|
+
* Gets the auto-remove button FieldDef from normalization metadata.
|
|
347
|
+
* Set by simplified array normalization for primitive arrays with remove buttons.
|
|
348
|
+
* The button is rendered alongside each item without wrapping in a row,
|
|
349
|
+
* preserving flat primitive form values.
|
|
350
|
+
*/
|
|
351
|
+
autoRemoveButton = computed(() => {
|
|
352
|
+
const metadata = getNormalizedArrayMetadata(this.field());
|
|
353
|
+
return metadata?.autoRemoveButton;
|
|
354
|
+
}, ...(ngDevMode ? [{ debugName: "autoRemoveButton" }] : /* istanbul ignore next */ []));
|
|
355
|
+
/**
|
|
356
|
+
* For primitive array items, the key of the value field template (e.g., 'value').
|
|
357
|
+
* Used to wrap the FormControl in the item context so getFieldTree(key) works.
|
|
358
|
+
* Returns undefined for object arrays (FormGroup items have natural child navigation).
|
|
359
|
+
*
|
|
360
|
+
* Detection order:
|
|
361
|
+
* 1. Normalization metadata (for simplified arrays — always available at normalization time)
|
|
362
|
+
* 2. Existing item definitions (non-empty full-API arrays)
|
|
363
|
+
* 3. Dynamically discovered key from handleAddFromEvent (empty full-API arrays)
|
|
364
|
+
*
|
|
365
|
+
* Uses linkedSignal: recomputes when field() changes, supports manual set()
|
|
366
|
+
* for the dynamic discovery case.
|
|
367
|
+
*/
|
|
368
|
+
primitiveFieldKey = linkedSignal(() => {
|
|
369
|
+
// Priority 1: Normalization metadata (simplified arrays always have this)
|
|
370
|
+
const metadata = getNormalizedArrayMetadata(this.field());
|
|
371
|
+
if (metadata?.primitiveFieldKey) {
|
|
372
|
+
return metadata.primitiveFieldKey;
|
|
373
|
+
}
|
|
374
|
+
// Priority 2: Existing item definitions (full-API arrays with initial items)
|
|
375
|
+
const definitions = this.field().fields || [];
|
|
376
|
+
if (definitions.length > 0 && !Array.isArray(definitions[0])) {
|
|
377
|
+
return definitions[0].key;
|
|
378
|
+
}
|
|
379
|
+
return undefined;
|
|
380
|
+
}, ...(ngDevMode ? [{ debugName: "primitiveFieldKey" }] : /* istanbul ignore next */ []));
|
|
381
|
+
/**
|
|
382
|
+
* Normalized item templates WITHOUT auto-remove button appended.
|
|
383
|
+
* Each element is normalized to an array: single FieldDef → [FieldDef], array stays as-is.
|
|
384
|
+
*
|
|
385
|
+
* Used by moveItem() to stash raw templates into the templateRegistry, preserving
|
|
386
|
+
* the invariant that registry entries are pre-synthesis (withAutoRemove() adds the
|
|
387
|
+
* button during resolution).
|
|
388
|
+
*/
|
|
389
|
+
rawItemTemplates = computed(() => {
|
|
390
|
+
const arrayField = this.field();
|
|
391
|
+
const definitions = arrayField.fields || [];
|
|
392
|
+
return definitions.map((def) => {
|
|
393
|
+
return (Array.isArray(def) ? def : [def]);
|
|
394
|
+
});
|
|
395
|
+
}, ...(ngDevMode ? [{ debugName: "rawItemTemplates" }] : /* istanbul ignore next */ []));
|
|
396
|
+
/**
|
|
397
|
+
* Resolves the effective fallback template for this array — used when the form
|
|
398
|
+
* value contains an item with no registered template (i.e., items that were
|
|
399
|
+
* neither added via event handlers nor covered by a positional entry in `fields`).
|
|
400
|
+
*
|
|
401
|
+
* Populated from `SimplifiedArrayField.template` via normalization metadata, so every
|
|
402
|
+
* simplified array gets an automatic default. Returns undefined for full-API arrays;
|
|
403
|
+
* `createResolveItemObservable` then falls back to warn-and-drop.
|
|
404
|
+
*
|
|
405
|
+
* Homogeneous arrays only — all fallback items receive the same template.
|
|
406
|
+
*/
|
|
407
|
+
fallbackTemplate = computed(() => {
|
|
408
|
+
const raw = getNormalizedArrayMetadata(this.field())?.template;
|
|
409
|
+
if (!raw)
|
|
410
|
+
return undefined;
|
|
411
|
+
return (Array.isArray(raw) ? [...raw] : [raw]);
|
|
412
|
+
}, ...(ngDevMode ? [{ debugName: "fallbackTemplate" }] : /* istanbul ignore next */ []));
|
|
413
|
+
/**
|
|
414
|
+
* Gets the item templates (field definitions) for the array.
|
|
415
|
+
* Each element can be either:
|
|
416
|
+
* - A single FieldDef (primitive item) - normalized to [FieldDef]
|
|
417
|
+
* - An array of FieldDefs (object item) - used as-is
|
|
418
|
+
*
|
|
419
|
+
* When auto-remove is configured, the remove button is appended to each item's
|
|
420
|
+
* template list for rendering. This is purely visual — the form schema uses the
|
|
421
|
+
* original primitive item definition (single FieldDef → FormControl → flat value).
|
|
422
|
+
*
|
|
423
|
+
* Returns normalized templates where all items are arrays for consistent handling.
|
|
424
|
+
*/
|
|
425
|
+
itemTemplates = computed(() => {
|
|
426
|
+
const raw = this.rawItemTemplates();
|
|
427
|
+
const removeButton = this.autoRemoveButton();
|
|
428
|
+
if (!removeButton)
|
|
429
|
+
return raw;
|
|
430
|
+
return raw.map((normalized) => {
|
|
431
|
+
return [...normalized, removeButton];
|
|
432
|
+
});
|
|
433
|
+
}, ...(ngDevMode ? [{ debugName: "itemTemplates" }] : /* istanbul ignore next */ []));
|
|
434
|
+
arrayFieldTrees = computed(() => {
|
|
435
|
+
const arrayKey = this.field().key;
|
|
436
|
+
const parentForm = this.parentFieldSignalContext.form;
|
|
437
|
+
const arrayValue = getArrayValue(this.parentFieldSignalContext.value(), arrayKey);
|
|
438
|
+
if (arrayValue.length === 0)
|
|
439
|
+
return [];
|
|
440
|
+
const arrayFieldTree = parentForm[arrayKey];
|
|
441
|
+
if (!arrayFieldTree)
|
|
442
|
+
return arrayValue.map(() => null);
|
|
443
|
+
// Access array items via bracket notation - Angular Signal Forms arrays support this
|
|
444
|
+
const items = [];
|
|
445
|
+
for (let i = 0; i < arrayValue.length; i++) {
|
|
446
|
+
// Access item FieldTree directly via numeric indexing (ArrayFieldTree supports this)
|
|
447
|
+
const itemFieldTree = arrayFieldTree[i];
|
|
448
|
+
items.push(itemFieldTree ?? null);
|
|
449
|
+
}
|
|
450
|
+
return items;
|
|
451
|
+
}, ...(ngDevMode ? [{ debugName: "arrayFieldTrees" }] : /* istanbul ignore next */ []));
|
|
452
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
453
|
+
// State Signals
|
|
454
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
455
|
+
resolvedItemsSignal = signal([], ...(ngDevMode ? [{ debugName: "resolvedItemsSignal" }] : /* istanbul ignore next */ []));
|
|
456
|
+
updateVersion = signal(0, ...(ngDevMode ? [{ debugName: "updateVersion" }] : /* istanbul ignore next */ []));
|
|
457
|
+
pendingInitializationCycle = signal(null, ...(ngDevMode ? [{ debugName: "pendingInitializationCycle" }] : /* istanbul ignore next */ []));
|
|
458
|
+
settledInitializationCycle = signal(null, ...(ngDevMode ? [{ debugName: "settledInitializationCycle" }] : /* istanbul ignore next */ []));
|
|
459
|
+
/**
|
|
460
|
+
* Map of item IDs to their current positions. O(1) lookup vs O(n) indexOf().
|
|
461
|
+
* Used by child linkedSignals to reactively track their position in the array.
|
|
462
|
+
*/
|
|
463
|
+
itemPositionMap = computed(() => {
|
|
464
|
+
const items = this.resolvedItemsSignal();
|
|
465
|
+
return new Map(items.map((item, idx) => [item.id, idx]));
|
|
466
|
+
}, ...(ngDevMode ? [{ debugName: "itemPositionMap" }] : /* istanbul ignore next */ []));
|
|
467
|
+
/** Read-only view of resolved items for template consumption. */
|
|
468
|
+
resolvedItems = computed(() => this.resolvedItemsSignal(), ...(ngDevMode ? [{ debugName: "resolvedItems" }] : /* istanbul ignore next */ []));
|
|
469
|
+
allResolvedFieldsRenderReady = computed(() => this.resolvedItems().every((item) => item.fields.every((field) => field.renderReady())), ...(ngDevMode ? [{ debugName: "allResolvedFieldsRenderReady" }] : /* istanbul ignore next */ []));
|
|
470
|
+
/**
|
|
471
|
+
* Whether the array has reached its configured maxLength.
|
|
472
|
+
* Exposed as a public signal so add-button components can bind [disabled]="atMaxLength()".
|
|
473
|
+
*/
|
|
474
|
+
atMaxLength = computed(() => {
|
|
475
|
+
const maxLength = this.field().maxLength;
|
|
476
|
+
if (maxLength === undefined)
|
|
477
|
+
return false;
|
|
478
|
+
const arrayKey = this.field().key;
|
|
479
|
+
const currentArray = getArrayValue(this.parentFieldSignalContext.value(), arrayKey);
|
|
480
|
+
return currentArray.length >= maxLength;
|
|
481
|
+
}, ...(ngDevMode ? [{ debugName: "atMaxLength" }] : /* istanbul ignore next */ []));
|
|
482
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
483
|
+
// Auto-remove Cache
|
|
484
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
485
|
+
/**
|
|
486
|
+
* Caches the result of appending the auto-remove button to template arrays.
|
|
487
|
+
* Keyed by template array reference — cache hits occur during recreate/resolution
|
|
488
|
+
* operations where stored template references are reused. Add operations via
|
|
489
|
+
* handleAddFromEvent always create a fresh `[...template]` copy (line ~277),
|
|
490
|
+
* so each add is a cache miss by design (the spread is needed for mutability).
|
|
491
|
+
*/
|
|
492
|
+
autoRemoveCache = new WeakMap();
|
|
493
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
494
|
+
// Constructor
|
|
495
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
496
|
+
constructor() {
|
|
497
|
+
this.setupEffects();
|
|
498
|
+
this.setupEventHandlers();
|
|
499
|
+
}
|
|
500
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
501
|
+
// Private Methods
|
|
502
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
503
|
+
setupEffects() {
|
|
504
|
+
// Sync field components when array data changes
|
|
505
|
+
explicitEffect([this.arrayFieldTrees], ([fieldTrees]) => {
|
|
506
|
+
this.performDifferentialUpdate(fieldTrees);
|
|
507
|
+
});
|
|
508
|
+
explicitEffect([this.pendingInitializationCycle, this.settledInitializationCycle, this.allResolvedFieldsRenderReady], ([pending, settled, allReady]) => {
|
|
509
|
+
if (pending !== null && settled === pending && allReady) {
|
|
510
|
+
emitComponentInitialized(this.eventBus, 'array', this.field().key, this.parentInjector);
|
|
511
|
+
this.pendingInitializationCycle.set(null);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
setupEventHandlers() {
|
|
516
|
+
observeArrayActions(this.eventBus, () => this.key())
|
|
517
|
+
.pipe(takeUntilDestroyed())
|
|
518
|
+
.subscribe((action) => {
|
|
519
|
+
if (action.action === 'add') {
|
|
520
|
+
// Template can be single FieldDef (primitive) or FieldDef[] (object)
|
|
521
|
+
const templates = Array.isArray(action.template) ? action.template : action.template ? [action.template] : [];
|
|
522
|
+
if (templates.length === 0) {
|
|
523
|
+
this.logger.error(`Cannot add item to array '${this.key()}': template is REQUIRED. ` +
|
|
524
|
+
'Buttons must specify an explicit template property. ' +
|
|
525
|
+
'There is no default template - each add operation must provide its own.');
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
void this.handleAddFromEvent(action.template, action.index);
|
|
529
|
+
}
|
|
530
|
+
else if (action.action === 'move') {
|
|
531
|
+
this.moveItem(action.fromIndex, action.toIndex);
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
this.removeItem(action.index);
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Handles add operations from events (append, prepend, insert).
|
|
540
|
+
* Creates resolved items FIRST, then updates form value.
|
|
541
|
+
* This ensures prepend/insert work correctly - differential update sees "none"
|
|
542
|
+
* because resolved items count already matches the new array length.
|
|
543
|
+
*
|
|
544
|
+
* Supports both primitive (single FieldDef) and object (FieldDef[]) templates.
|
|
545
|
+
*/
|
|
546
|
+
async handleAddFromEvent(template, index) {
|
|
547
|
+
// Normalize template to mutable array for consistent handling.
|
|
548
|
+
// A single FieldDef with valueHandling: 'flatten' (container, row) is an object item
|
|
549
|
+
// whose children should be flattened — NOT a primitive item. Only true leaf fields
|
|
550
|
+
// (input, checkbox, etc.) are primitive when passed as a single FieldDef.
|
|
551
|
+
const templates = Array.isArray(template) ? [...template] : [template];
|
|
552
|
+
const isSingleField = !Array.isArray(template);
|
|
553
|
+
const isPrimitiveItem = isSingleField && getFieldValueHandling(templates[0].type, this.rawFieldRegistry()) !== 'flatten';
|
|
554
|
+
// Track primitive field key for full-API arrays that start empty.
|
|
555
|
+
// Simplified arrays already have this info from normalization metadata.
|
|
556
|
+
if (isPrimitiveItem && templates[0].key && !this.primitiveFieldKey()) {
|
|
557
|
+
this.primitiveFieldKey.set(templates[0].key);
|
|
558
|
+
}
|
|
559
|
+
if (templates.length === 0) {
|
|
560
|
+
this.logger.error(`Cannot add item to array '${this.field().key}': no field templates provided. ` +
|
|
561
|
+
'Buttons must specify an explicit template property when adding array items.');
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const arrayKey = this.field().key;
|
|
565
|
+
const parentForm = this.parentFieldSignalContext.form;
|
|
566
|
+
const currentValue = parentForm().value();
|
|
567
|
+
const currentArray = getArrayValue(currentValue, arrayKey);
|
|
568
|
+
const insertIndex = index !== undefined ? Math.min(index, currentArray.length) : currentArray.length;
|
|
569
|
+
// Compute default value
|
|
570
|
+
let value;
|
|
571
|
+
if (isPrimitiveItem) {
|
|
572
|
+
// Primitive item: single field's value is extracted directly
|
|
573
|
+
value = getFieldDefaultValue(templates[0], this.rawFieldRegistry());
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
// Object item: merge all template defaults into an object
|
|
577
|
+
value = {};
|
|
578
|
+
for (const templateField of templates) {
|
|
579
|
+
const rawValue = getFieldDefaultValue(templateField, this.rawFieldRegistry());
|
|
580
|
+
const valueHandling = getFieldValueHandling(templateField.type, this.rawFieldRegistry());
|
|
581
|
+
const isContainer = templateField.type === 'group' || templateField.type === 'row' || templateField.type === 'container';
|
|
582
|
+
if (isContainer) {
|
|
583
|
+
if (isGroupField(templateField)) {
|
|
584
|
+
// Groups wrap their fields under the group key
|
|
585
|
+
value = { ...value, [templateField.key]: rawValue };
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
// Rows and containers flatten their fields directly
|
|
589
|
+
value = { ...value, ...rawValue };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
else if (valueHandling === 'include' && templateField.key) {
|
|
593
|
+
value = { ...value, [templateField.key]: rawValue };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// Increment version and create resolved item BEFORE updating value
|
|
598
|
+
this.updateVersion.update((v) => v + 1);
|
|
599
|
+
const currentVersion = this.updateVersion();
|
|
600
|
+
// Append auto-remove button to resolution templates (for rendering only)
|
|
601
|
+
const resolveTemplates = this.withAutoRemove(templates);
|
|
602
|
+
// Resolve the new item
|
|
603
|
+
const resolvedItem = await firstValueFrom(resolveArrayItem({
|
|
604
|
+
index: insertIndex,
|
|
605
|
+
templates: resolveTemplates,
|
|
606
|
+
arrayField: this.field(),
|
|
607
|
+
itemPositionMap: this.itemPositionMap,
|
|
608
|
+
parentFieldSignalContext: this.parentFieldSignalContext,
|
|
609
|
+
parentInjector: this.parentInjector,
|
|
610
|
+
registry: this.rawFieldRegistry(),
|
|
611
|
+
destroyRef: this.destroyRef,
|
|
612
|
+
loadTypeComponent: (type) => this.fieldRegistry.loadTypeComponent(type),
|
|
613
|
+
generateItemId: this.generateItemId,
|
|
614
|
+
primitiveFieldKey: isPrimitiveItem ? templates[0].key : undefined,
|
|
615
|
+
}).pipe(catchError((error) => {
|
|
616
|
+
this.logger.error(`Failed to resolve array item at index ${insertIndex}:`, error);
|
|
617
|
+
return of(undefined);
|
|
618
|
+
})));
|
|
619
|
+
if (currentVersion !== this.updateVersion() || !resolvedItem) {
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
// Store the template used for this item so it can be re-used during recreate operations
|
|
623
|
+
this.templateRegistry.set(resolvedItem.id, templates);
|
|
624
|
+
// Insert resolved item at correct position
|
|
625
|
+
this.resolvedItemsSignal.update((current) => {
|
|
626
|
+
const newItems = [...current];
|
|
627
|
+
newItems.splice(insertIndex, 0, resolvedItem);
|
|
628
|
+
return newItems;
|
|
629
|
+
});
|
|
630
|
+
// Update form value - differential update sees "none" (lengths match)
|
|
631
|
+
const newArray = [...currentArray];
|
|
632
|
+
newArray.splice(insertIndex, 0, value);
|
|
633
|
+
// The `as any` is required because Angular Signal Forms uses complex conditional types
|
|
634
|
+
// that cannot infer the correct type when setting a dynamically-keyed property
|
|
635
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
636
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
637
|
+
}
|
|
638
|
+
performDifferentialUpdate(fieldTrees) {
|
|
639
|
+
const resolvedItems = this.resolvedItemsSignal();
|
|
640
|
+
const operation = determineDifferentialOperation(resolvedItems, fieldTrees.length);
|
|
641
|
+
// Only increment version for operations that do actual work.
|
|
642
|
+
// "none" operations (value-only changes) should not interfere with async add operations.
|
|
643
|
+
if (operation.type === 'none') {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
this.updateVersion.update((v) => v + 1);
|
|
647
|
+
const currentVersion = this.updateVersion();
|
|
648
|
+
switch (operation.type) {
|
|
649
|
+
case 'clear':
|
|
650
|
+
// Clean up template registry when all items are removed
|
|
651
|
+
this.templateRegistry.clear();
|
|
652
|
+
this.resolvedItemsSignal.set([]);
|
|
653
|
+
if (resolvedItems.length === 0 && this.pendingInitializationCycle() === null && this.settledInitializationCycle() === null) {
|
|
654
|
+
this.pendingInitializationCycle.set(currentVersion);
|
|
655
|
+
this.settledInitializationCycle.set(currentVersion);
|
|
656
|
+
}
|
|
657
|
+
break;
|
|
658
|
+
case 'initial':
|
|
659
|
+
this.pendingInitializationCycle.set(currentVersion);
|
|
660
|
+
this.settledInitializationCycle.set(null);
|
|
661
|
+
void this.resolveAllItems(fieldTrees, currentVersion);
|
|
662
|
+
break;
|
|
663
|
+
case 'append':
|
|
664
|
+
void this.appendItems(fieldTrees, operation.startIndex, operation.endIndex, currentVersion);
|
|
665
|
+
break;
|
|
666
|
+
case 'recreate': {
|
|
667
|
+
// Capture templates by item ID so each item is recreated with its original template,
|
|
668
|
+
// even after move operations that change positions. Registry entries (from dynamic adds
|
|
669
|
+
// or moves) take priority over positional itemTemplates lookup.
|
|
670
|
+
const positionalTemplates = resolvedItems.map((item) => {
|
|
671
|
+
// Registry entries (from dynamic adds or moves) take priority;
|
|
672
|
+
// unmoved initial items return undefined → falls through to itemTemplates[idx] during resolve.
|
|
673
|
+
return this.templateRegistry.get(item.id);
|
|
674
|
+
});
|
|
675
|
+
this.resolvedItemsSignal.set([]);
|
|
676
|
+
this.pendingInitializationCycle.set(currentVersion);
|
|
677
|
+
this.settledInitializationCycle.set(null);
|
|
678
|
+
void this.resolveAllItems(fieldTrees, currentVersion, positionalTemplates);
|
|
679
|
+
break;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
async resolveAllItems(fieldTrees, updateId, positionalTemplates) {
|
|
684
|
+
if (fieldTrees.length === 0) {
|
|
685
|
+
this.resolvedItemsSignal.set([]);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Wrap each item observable to catch individual errors
|
|
689
|
+
const safeItemObservables = fieldTrees.map((_, i) => this.createResolveItemObservable(i, positionalTemplates?.[i]).pipe(catchError((error) => {
|
|
690
|
+
this.logger.error(`Failed to resolve array item at index ${i}:`, error);
|
|
691
|
+
return of(undefined);
|
|
692
|
+
})));
|
|
693
|
+
try {
|
|
694
|
+
const items = await firstValueFrom(forkJoin(safeItemObservables).pipe(map((items) => items.filter((item) => item !== undefined))));
|
|
695
|
+
if (updateId === this.updateVersion()) {
|
|
696
|
+
// Update template registry with new item IDs for dynamically added items
|
|
697
|
+
if (positionalTemplates) {
|
|
698
|
+
// Clean up old entries and add new ones
|
|
699
|
+
const newItemIds = new Set(items.map((item) => item.id));
|
|
700
|
+
for (const existingId of this.templateRegistry.keys()) {
|
|
701
|
+
if (!newItemIds.has(existingId)) {
|
|
702
|
+
this.templateRegistry.delete(existingId);
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
items.forEach((item, idx) => {
|
|
706
|
+
const template = positionalTemplates[idx];
|
|
707
|
+
if (template) {
|
|
708
|
+
this.templateRegistry.set(item.id, template);
|
|
709
|
+
}
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
this.resolvedItemsSignal.set(items);
|
|
713
|
+
if (this.pendingInitializationCycle() === updateId) {
|
|
714
|
+
this.settledInitializationCycle.set(updateId);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
this.logger.error('Failed to resolve array items:', err);
|
|
720
|
+
this.resolvedItemsSignal.set([]);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async appendItems(fieldTrees, startIndex, endIndex, updateId) {
|
|
724
|
+
const itemsToResolve = fieldTrees.slice(startIndex, endIndex);
|
|
725
|
+
if (itemsToResolve.length === 0)
|
|
726
|
+
return;
|
|
727
|
+
// Wrap each item observable to catch individual errors
|
|
728
|
+
const safeItemObservables = itemsToResolve.map((_, i) => {
|
|
729
|
+
const index = startIndex + i;
|
|
730
|
+
return this.createResolveItemObservable(index).pipe(catchError((error) => {
|
|
731
|
+
this.logger.error(`Failed to resolve array item at index ${index}:`, error);
|
|
732
|
+
return of(undefined);
|
|
733
|
+
}));
|
|
734
|
+
});
|
|
735
|
+
try {
|
|
736
|
+
const newItems = await firstValueFrom(forkJoin(safeItemObservables).pipe(map((items) => items.filter((item) => item !== undefined))));
|
|
737
|
+
if (updateId === this.updateVersion()) {
|
|
738
|
+
this.resolvedItemsSignal.update((current) => [...current, ...newItems]);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
this.logger.error('Failed to append array items:', err);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Creates an observable that resolves a single array item.
|
|
747
|
+
*
|
|
748
|
+
* Template resolution order:
|
|
749
|
+
* 1. Use overrideTemplate if provided (from recreate with stored templates)
|
|
750
|
+
* 2. Use itemTemplates[index] if within defined templates range
|
|
751
|
+
* 3. Use the simplified-array fallback template (from normalization metadata) for
|
|
752
|
+
* untracked items present in the form value (e.g., external `value.set`, parent
|
|
753
|
+
* two-way binding, initial values beyond what was declared via simplified `value`).
|
|
754
|
+
* Registers the resolved item in templateRegistry so subsequent recreates use
|
|
755
|
+
* Priority 1.
|
|
756
|
+
* 4. Return undefined (item cannot be resolved without a template)
|
|
757
|
+
*/
|
|
758
|
+
createResolveItemObservable(index, overrideTemplate) {
|
|
759
|
+
const itemTemplates = this.itemTemplates();
|
|
760
|
+
const primitiveKey = this.primitiveFieldKey();
|
|
761
|
+
// Priority 1: Use override template (from recreate with stored templates)
|
|
762
|
+
if (overrideTemplate && overrideTemplate.length > 0) {
|
|
763
|
+
return resolveArrayItem({
|
|
764
|
+
index,
|
|
765
|
+
templates: this.withAutoRemove(overrideTemplate),
|
|
766
|
+
arrayField: this.field(),
|
|
767
|
+
itemPositionMap: this.itemPositionMap,
|
|
768
|
+
parentFieldSignalContext: this.parentFieldSignalContext,
|
|
769
|
+
parentInjector: this.parentInjector,
|
|
770
|
+
registry: this.rawFieldRegistry(),
|
|
771
|
+
destroyRef: this.destroyRef,
|
|
772
|
+
loadTypeComponent: (type) => this.fieldRegistry.loadTypeComponent(type),
|
|
773
|
+
generateItemId: this.generateItemId,
|
|
774
|
+
primitiveFieldKey: primitiveKey,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
// Priority 2: Use defined template at this index
|
|
778
|
+
const templates = itemTemplates[index];
|
|
779
|
+
if (templates && templates.length > 0) {
|
|
780
|
+
return resolveArrayItem({
|
|
781
|
+
index,
|
|
782
|
+
templates: templates,
|
|
783
|
+
arrayField: this.field(),
|
|
784
|
+
itemPositionMap: this.itemPositionMap,
|
|
785
|
+
parentFieldSignalContext: this.parentFieldSignalContext,
|
|
786
|
+
parentInjector: this.parentInjector,
|
|
787
|
+
registry: this.rawFieldRegistry(),
|
|
788
|
+
destroyRef: this.destroyRef,
|
|
789
|
+
loadTypeComponent: (type) => this.fieldRegistry.loadTypeComponent(type),
|
|
790
|
+
generateItemId: this.generateItemId,
|
|
791
|
+
primitiveFieldKey: primitiveKey,
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
// Priority 3: Use the metadata fallback template for untracked items in the form value.
|
|
795
|
+
// Homogeneous only — every fallback item receives the same template regardless of value shape.
|
|
796
|
+
const fallback = this.fallbackTemplate();
|
|
797
|
+
if (fallback && fallback.length > 0) {
|
|
798
|
+
return resolveArrayItem({
|
|
799
|
+
index,
|
|
800
|
+
templates: this.withAutoRemove(fallback),
|
|
801
|
+
arrayField: this.field(),
|
|
802
|
+
itemPositionMap: this.itemPositionMap,
|
|
803
|
+
parentFieldSignalContext: this.parentFieldSignalContext,
|
|
804
|
+
parentInjector: this.parentInjector,
|
|
805
|
+
registry: this.rawFieldRegistry(),
|
|
806
|
+
destroyRef: this.destroyRef,
|
|
807
|
+
loadTypeComponent: (type) => this.fieldRegistry.loadTypeComponent(type),
|
|
808
|
+
generateItemId: this.generateItemId,
|
|
809
|
+
primitiveFieldKey: primitiveKey,
|
|
810
|
+
}).pipe(tap((item) => {
|
|
811
|
+
// Register the fallback template against the generated item id so subsequent
|
|
812
|
+
// recreates hit Priority 1 instead of re-resolving via this branch.
|
|
813
|
+
if (item)
|
|
814
|
+
this.templateRegistry.set(item.id, fallback);
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
// Priority 4: no template available. Full-API arrays are positional by design —
|
|
818
|
+
// `fields` declares one template per item, so values extending past `fields.length`
|
|
819
|
+
// (e.g., from external `value.set`, parent two-way binding, or initial values on a
|
|
820
|
+
// `fields: []` array) have no template to render. Use the simplified array API
|
|
821
|
+
// (`template` + `value`) when you need homogeneous arrays with value-driven items.
|
|
822
|
+
this.logger.warn(`No template found for array item at index ${index}. This likely occured for a Full-API array element that was created from the DynamicForm's value being set directly, which is currently not supported. Consider using the simplified array API.`);
|
|
823
|
+
return of(undefined);
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Appends the auto-remove button to a templates array for rendering.
|
|
827
|
+
* The button is added for visual rendering only — it doesn't affect the form schema.
|
|
828
|
+
* Original templates are stored in templateRegistry WITHOUT the remove button,
|
|
829
|
+
* so this method is called during resolution to add it dynamically.
|
|
830
|
+
*
|
|
831
|
+
* Uses a WeakMap cache keyed by template array reference. Cache hits occur
|
|
832
|
+
* during recreate/resolution paths where stored templates are reused.
|
|
833
|
+
* Add operations always pass a fresh `[...template]` copy, so they miss
|
|
834
|
+
* the cache intentionally (the copy is needed for mutable item construction).
|
|
835
|
+
*/
|
|
836
|
+
withAutoRemove(templates) {
|
|
837
|
+
const removeButton = this.autoRemoveButton();
|
|
838
|
+
if (!removeButton)
|
|
839
|
+
return templates;
|
|
840
|
+
let cached = this.autoRemoveCache.get(templates);
|
|
841
|
+
if (cached)
|
|
842
|
+
return cached;
|
|
843
|
+
cached = [...templates, removeButton];
|
|
844
|
+
this.autoRemoveCache.set(templates, cached);
|
|
845
|
+
return cached;
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* Handles move operations — reorders an existing item without destroying it.
|
|
849
|
+
* Updates resolvedItems and form value atomically. Since the array length
|
|
850
|
+
* doesn't change, `determineDifferentialOperation` returns 'none' and no
|
|
851
|
+
* recreate is triggered. The `@for` loop tracks by `item.id`, so Angular
|
|
852
|
+
* moves the DOM node instead of destroying/recreating. The `itemPositionMap`
|
|
853
|
+
* computed auto-recomputes, propagating new indices to child linkedSignals.
|
|
854
|
+
*/
|
|
855
|
+
moveItem(fromIndex, toIndex) {
|
|
856
|
+
const arrayKey = this.field().key;
|
|
857
|
+
const parentForm = this.parentFieldSignalContext.form;
|
|
858
|
+
const currentValue = parentForm().value();
|
|
859
|
+
const currentArray = getArrayValue(currentValue, arrayKey);
|
|
860
|
+
const length = currentArray.length;
|
|
861
|
+
if (fromIndex < 0 || fromIndex >= length) {
|
|
862
|
+
this.logger.warn(`moveArrayItem fromIndex ${fromIndex} is out of bounds for array '${arrayKey}' with length ${length}. Operation skipped.`);
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (toIndex < 0 || toIndex >= length) {
|
|
866
|
+
this.logger.warn(`moveArrayItem toIndex ${toIndex} is out of bounds for array '${arrayKey}' with length ${length}. Operation skipped.`);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
if (fromIndex === toIndex)
|
|
870
|
+
return;
|
|
871
|
+
// Register raw (pre-auto-remove) templates for all items whose position will change.
|
|
872
|
+
// Initial items use itemTemplates[currentIndex] during recreate, but after
|
|
873
|
+
// a move their position no longer matches their original template. Stashing
|
|
874
|
+
// the template by item ID lets the recreate path resolve the correct one.
|
|
875
|
+
// We use rawItemTemplates (without auto-remove button) because the recreate path
|
|
876
|
+
// calls withAutoRemove() during resolution — storing the synthesized version would
|
|
877
|
+
// cause duplicate remove buttons.
|
|
878
|
+
const rawTemplates = this.rawItemTemplates();
|
|
879
|
+
const currentItems = this.resolvedItemsSignal();
|
|
880
|
+
const lo = Math.min(fromIndex, toIndex);
|
|
881
|
+
const hi = Math.max(fromIndex, toIndex);
|
|
882
|
+
for (let i = lo; i <= hi; i++) {
|
|
883
|
+
const item = currentItems[i];
|
|
884
|
+
if (item && !this.templateRegistry.has(item.id) && i < rawTemplates.length) {
|
|
885
|
+
this.templateRegistry.set(item.id, rawTemplates[i]);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
// Reorder resolvedItems — splice preserves object identity (no destroy/recreate)
|
|
889
|
+
this.resolvedItemsSignal.update((current) => {
|
|
890
|
+
const newItems = [...current];
|
|
891
|
+
const [moved] = newItems.splice(fromIndex, 1);
|
|
892
|
+
newItems.splice(toIndex, 0, moved);
|
|
893
|
+
return newItems;
|
|
894
|
+
});
|
|
895
|
+
// Reorder form value array the same way
|
|
896
|
+
const newArray = [...currentArray];
|
|
897
|
+
const [movedValue] = newArray.splice(fromIndex, 1);
|
|
898
|
+
newArray.splice(toIndex, 0, movedValue);
|
|
899
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
900
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Handles remove operations from events (pop, shift, removeAt).
|
|
904
|
+
* Updates resolvedItems FIRST, then form value - this ensures differential
|
|
905
|
+
* update sees "none" (lengths match) and avoids unnecessary recreates.
|
|
906
|
+
* Remaining items' linkedSignal indices auto-update via itemPositionMap.
|
|
907
|
+
*/
|
|
908
|
+
removeItem(index) {
|
|
909
|
+
const arrayKey = this.field().key;
|
|
910
|
+
const parentForm = this.parentFieldSignalContext.form;
|
|
911
|
+
const currentValue = parentForm().value();
|
|
912
|
+
const currentArray = getArrayValue(currentValue, arrayKey);
|
|
913
|
+
if (currentArray.length === 0)
|
|
914
|
+
return;
|
|
915
|
+
// When index is undefined or -1, remove the last item.
|
|
916
|
+
let removeIndex;
|
|
917
|
+
if (index === undefined || index === -1) {
|
|
918
|
+
removeIndex = currentArray.length - 1;
|
|
919
|
+
}
|
|
920
|
+
else if (index < -1 || index >= currentArray.length) {
|
|
921
|
+
this.logger.warn(`removeArrayItem index ${index} is out of bounds for array '${arrayKey}' with length ${currentArray.length}. Operation skipped.`);
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
else {
|
|
925
|
+
removeIndex = index;
|
|
926
|
+
}
|
|
927
|
+
// Update resolvedItems FIRST - remove the item at the specified index.
|
|
928
|
+
// This ensures differential update sees "none" (lengths already match).
|
|
929
|
+
// Remaining items' linkedSignal indices auto-update via itemPositionMap.
|
|
930
|
+
//
|
|
931
|
+
// When the item is removed from resolvedItemsSignal, the @for loop removes the DOM element
|
|
932
|
+
// and NgComponentOutlet destroys the component view, triggering DestroyRef callbacks.
|
|
933
|
+
// Async validators use Angular's resource API tied to the form-level schema path — when the
|
|
934
|
+
// array value is updated below, the resource's params re-evaluate. If the removed item's path
|
|
935
|
+
// no longer exists, params returns undefined, cancelling the pending validation automatically.
|
|
936
|
+
const removedItem = this.resolvedItemsSignal()[removeIndex];
|
|
937
|
+
if (removedItem) {
|
|
938
|
+
this.templateRegistry.delete(removedItem.id);
|
|
939
|
+
}
|
|
940
|
+
this.resolvedItemsSignal.update((current) => {
|
|
941
|
+
const newItems = [...current];
|
|
942
|
+
newItems.splice(removeIndex, 1);
|
|
943
|
+
return newItems;
|
|
944
|
+
});
|
|
945
|
+
// Update the parent form with the new array value
|
|
946
|
+
const newArray = [...currentArray];
|
|
947
|
+
newArray.splice(removeIndex, 1);
|
|
948
|
+
// The `as any` is required due to Angular Signal Forms' complex conditional types
|
|
949
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
950
|
+
parentForm().value.set({ ...currentValue, [arrayKey]: newArray });
|
|
951
|
+
}
|
|
952
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
953
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.8", type: ArrayFieldComponent, isStandalone: true, selector: "array-field", inputs: { field: { classPropertyName: "field", publicName: "field", isSignal: true, isRequired: true, transformFunction: null }, key: { classPropertyName: "key", publicName: "key", isSignal: true, isRequired: true, transformFunction: null }, className: { classPropertyName: "className", publicName: "className", isSignal: true, isRequired: false, transformFunction: null }, hidden: { classPropertyName: "hidden", publicName: "hidden", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "class": "hostClasses()", "class.df-container-hidden": "hidden()", "attr.aria-hidden": "hidden() || null", "id": "`${key()}`", "attr.data-testid": "key()" } }, providers: [
|
|
954
|
+
// Each array gets its own template registry to track templates used for dynamically added items
|
|
955
|
+
{ provide: ARRAY_TEMPLATE_REGISTRY, useFactory: () => new Map() },
|
|
956
|
+
// Each array gets its own ID generator for SSR hydration compatibility
|
|
957
|
+
{ provide: ARRAY_ITEM_ID_GENERATOR, useFactory: createArrayItemIdGenerator },
|
|
958
|
+
], ngImport: i0, template: `
|
|
959
|
+
@for (item of resolvedItems(); track item.id; let i = $index) {
|
|
960
|
+
<div
|
|
961
|
+
class="df-array-item"
|
|
962
|
+
role="group"
|
|
963
|
+
[attr.aria-label]="'Item ' + (i + 1)"
|
|
964
|
+
[attr.data-array-item-id]="item.id"
|
|
965
|
+
[attr.data-array-item-index]="i"
|
|
966
|
+
>
|
|
967
|
+
@for (field of item.fields; track $index) {
|
|
968
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
969
|
+
}
|
|
970
|
+
</div>
|
|
971
|
+
}
|
|
972
|
+
`, 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-array-item-gap, var(--df-grid-row-gap, .5rem));width:100%}:host>*{grid-column:1/-1}:host.df-container-hidden{display:none}.df-array-item{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}.df-array-item>*{grid-column:1/-1}\n"], dependencies: [{ kind: "directive", type: DfFieldOutlet, selector: "[dfFieldOutlet]", inputs: ["dfFieldOutlet", "dfFieldOutletEnvironmentInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
973
|
+
}
|
|
974
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.8", ngImport: i0, type: ArrayFieldComponent, decorators: [{
|
|
975
|
+
type: Component,
|
|
976
|
+
args: [{ selector: 'array-field', imports: [DfFieldOutlet], template: `
|
|
977
|
+
@for (item of resolvedItems(); track item.id; let i = $index) {
|
|
978
|
+
<div
|
|
979
|
+
class="df-array-item"
|
|
980
|
+
role="group"
|
|
981
|
+
[attr.aria-label]="'Item ' + (i + 1)"
|
|
982
|
+
[attr.data-array-item-id]="item.id"
|
|
983
|
+
[attr.data-array-item-index]="i"
|
|
984
|
+
>
|
|
985
|
+
@for (field of item.fields; track $index) {
|
|
986
|
+
<ng-container *dfFieldOutlet="field; environmentInjector: environmentInjector" />
|
|
987
|
+
}
|
|
988
|
+
</div>
|
|
989
|
+
}
|
|
990
|
+
`, host: {
|
|
991
|
+
'[class]': 'hostClasses()',
|
|
992
|
+
'[class.df-container-hidden]': 'hidden()',
|
|
993
|
+
'[attr.aria-hidden]': 'hidden() || null',
|
|
994
|
+
'[id]': '`${key()}`',
|
|
995
|
+
'[attr.data-testid]': 'key()',
|
|
996
|
+
}, providers: [
|
|
997
|
+
// Each array gets its own template registry to track templates used for dynamically added items
|
|
998
|
+
{ provide: ARRAY_TEMPLATE_REGISTRY, useFactory: () => new Map() },
|
|
999
|
+
// Each array gets its own ID generator for SSR hydration compatibility
|
|
1000
|
+
{ provide: ARRAY_ITEM_ID_GENERATOR, useFactory: createArrayItemIdGenerator },
|
|
1001
|
+
], 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-array-item-gap, var(--df-grid-row-gap, .5rem));width:100%}:host>*{grid-column:1/-1}:host.df-container-hidden{display:none}.df-array-item{display:grid;grid-template-columns:1fr;gap:var(--df-grid-row-gap, .5rem);width:100%}.df-array-item>*{grid-column:1/-1}\n"] }]
|
|
1002
|
+
}], ctorParameters: () => [], propDecorators: { field: [{ type: i0.Input, args: [{ isSignal: true, alias: "field", required: true }] }], key: [{ type: i0.Input, args: [{ isSignal: true, alias: "key", required: true }] }], className: [{ type: i0.Input, args: [{ isSignal: true, alias: "className", required: false }] }], hidden: [{ type: i0.Input, args: [{ isSignal: true, alias: "hidden", required: false }] }] } });
|
|
1003
|
+
|
|
1004
|
+
export { ArrayFieldComponent, ArrayFieldComponent as default };
|
|
1005
|
+
//# sourceMappingURL=ng-forge-dynamic-forms-array-field.component-CJ_OclbR.mjs.map
|