ng-form-foundry 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import * as i1 from '@angular/forms';
2
2
  import { Validators, FormControl, FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
3
3
  import * as i0 from '@angular/core';
4
- import { Input, Component, EventEmitter, Output, inject, ChangeDetectorRef, forwardRef, ViewChildren, HostBinding } from '@angular/core';
4
+ import { Input, Component, EventEmitter, Output, inject, ChangeDetectorRef, forwardRef, ViewChildren, HostBinding, input, output, model, computed } from '@angular/core';
5
5
  import * as i2 from '@angular/material/form-field';
6
6
  import { MatFormFieldModule } from '@angular/material/form-field';
7
7
  import * as i3$1 from '@angular/material/input';
@@ -10,17 +10,18 @@ import * as i4 from '@angular/material/checkbox';
10
10
  import { MatCheckboxModule } from '@angular/material/checkbox';
11
11
  import * as i3 from '@angular/material/select';
12
12
  import { MatSelectModule } from '@angular/material/select';
13
- import * as i1$1 from '@angular/material/icon';
13
+ import * as i2$1 from '@angular/material/icon';
14
14
  import { MatIconModule } from '@angular/material/icon';
15
- import * as i3$2 from '@angular/material/button';
15
+ import * as i4$1 from '@angular/material/button';
16
16
  import { MatButtonModule } from '@angular/material/button';
17
- import { MatTooltipModule, MatTooltip } from '@angular/material/tooltip';
17
+ import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip';
18
18
  import { MatDialog } from '@angular/material/dialog';
19
- import * as i2$1 from '@angular/material/expansion';
19
+ import * as i2$2 from '@angular/material/expansion';
20
20
  import { MatExpansionModule } from '@angular/material/expansion';
21
- import * as i5 from '@angular/material/card';
22
21
  import { MatCardModule } from '@angular/material/card';
23
22
  import { NgTemplateOutlet } from '@angular/common';
23
+ import * as i4$2 from '@angular/material/menu';
24
+ import { MatMenuModule } from '@angular/material/menu';
24
25
 
25
26
  /** The control name that records which case of a {@link NodeChoice} is active. */
26
27
  const CASE_KEY = '__case';
@@ -41,10 +42,74 @@ function isNodeGroupList(node) {
41
42
  function isChoice(node) {
42
43
  return node.kind === 'choice';
43
44
  }
45
+ function isMap(node) {
46
+ return node.kind === 'map';
47
+ }
44
48
  function enumValidator(choices) {
45
49
  const set = new Set(choices);
46
50
  return (ctrl) => ctrl.value == null || set.has(ctrl.value) ? null : { enum: true };
47
51
  }
52
+ /**
53
+ * JSON Schema `pattern`: an *unanchored* `RegExp.test`, unlike Angular's built-in
54
+ * `Validators.pattern` which anchors the expression. An invalid regex disables
55
+ * the check rather than throwing. Empty/absent values pass (use `required`).
56
+ */
57
+ function patternValidator(pattern) {
58
+ let re;
59
+ try {
60
+ re = new RegExp(pattern);
61
+ }
62
+ catch {
63
+ return () => null;
64
+ }
65
+ return (ctrl) => {
66
+ const v = ctrl.value;
67
+ if (v == null || v === '')
68
+ return null;
69
+ return re.test(String(v)) ? null : { pattern: { requiredPattern: pattern, actualValue: v } };
70
+ };
71
+ }
72
+ /** JSON Schema `type: 'integer'`: reject a value that is not a whole number. */
73
+ function integerValidator() {
74
+ return (ctrl) => {
75
+ const v = ctrl.value;
76
+ if (v == null || v === '')
77
+ return null;
78
+ const num = typeof v === 'number' ? v : Number(v);
79
+ return Number.isInteger(num) ? null : { integer: true };
80
+ };
81
+ }
82
+ /** JSON Schema `multipleOf`: reject a value that is not an integer multiple of `step`. */
83
+ function multipleOfValidator(step) {
84
+ return (ctrl) => {
85
+ const v = ctrl.value;
86
+ if (v == null || v === '')
87
+ return null;
88
+ const num = typeof v === 'number' ? v : Number(v);
89
+ if (Number.isNaN(num))
90
+ return null;
91
+ const ratio = num / step;
92
+ // A small tolerance absorbs binary float drift (e.g. 0.3 / 0.1).
93
+ return Math.abs(ratio - Math.round(ratio)) < 1e-9
94
+ ? null
95
+ : { multipleOf: { multipleOf: step, actual: num } };
96
+ };
97
+ }
98
+ /** JSON Schema `format: uri`: reject a string that is not a parseable absolute URI. */
99
+ function uriValidator() {
100
+ return (ctrl) => {
101
+ const v = ctrl.value;
102
+ if (v == null || v === '')
103
+ return null;
104
+ try {
105
+ new URL(String(v));
106
+ return null;
107
+ }
108
+ catch {
109
+ return { uri: true };
110
+ }
111
+ };
112
+ }
48
113
  function buildLeafControl(leaf, initial) {
49
114
  const validators = [];
50
115
  if ('required' in leaf && leaf.required)
@@ -53,9 +118,38 @@ function buildLeafControl(leaf, initial) {
53
118
  const choices = leaf.enum;
54
119
  validators.push(enumValidator(choices));
55
120
  }
121
+ if (leaf.type === 'string') {
122
+ const s = leaf;
123
+ if (s.pattern != null)
124
+ validators.push(patternValidator(s.pattern));
125
+ if (s.minLength != null)
126
+ validators.push(Validators.minLength(s.minLength));
127
+ if (s.maxLength != null)
128
+ validators.push(Validators.maxLength(s.maxLength));
129
+ if (s.format === 'email')
130
+ validators.push(Validators.email);
131
+ else if (s.format === 'uri' || s.format === 'url')
132
+ validators.push(uriValidator());
133
+ }
134
+ else if (leaf.type === 'number') {
135
+ const n = leaf;
136
+ if (n.integer)
137
+ validators.push(integerValidator());
138
+ if (n.min != null)
139
+ validators.push(Validators.min(n.min));
140
+ if (n.max != null)
141
+ validators.push(Validators.max(n.max));
142
+ if (n.multipleOf != null)
143
+ validators.push(multipleOfValidator(n.multipleOf));
144
+ }
56
145
  const defaultValue = initial ?? ('default' in leaf ? leaf.default : undefined) ?? null;
146
+ // A nullable leaf drops `nonNullable`, so `null` is a first-class value that
147
+ // `reset()` restores and that survives the round-trip (JSON Schema `null`).
148
+ // The typed model still treats a leaf value as non-null, so the runtime
149
+ // nullable control is cast back to the declared type.
150
+ const nullable = 'nullable' in leaf && leaf.nullable === true;
57
151
  return new FormControl(defaultValue, {
58
- nonNullable: true,
152
+ nonNullable: !nullable,
59
153
  validators,
60
154
  });
61
155
  }
@@ -98,17 +192,144 @@ function buildNodeGroupListControl(list, initial = null) {
98
192
  * name plus that case's field controls. Only the active case's fields are built,
99
193
  * matching the inline YANG encoding; switching the case swaps them.
100
194
  */
195
+ /**
196
+ * Normalize a {@link ChoiceCase} to a field record. A field record is returned
197
+ * as-is; a single node (a leaf-bodied case, e.g. a scalar `anyOf` branch) becomes
198
+ * a one-field record keyed by the node's `name`. The discriminant is a top-level
199
+ * `kind` string, which a field record never has (its keys are field names).
200
+ */
201
+ function caseFields(body) {
202
+ return typeof body.kind === 'string'
203
+ ? { [body.name]: body }
204
+ : body;
205
+ }
206
+ /**
207
+ * The active case of a choice: an explicit `__case` in the initial value, else
208
+ * the case whose fields best match the initial data (inline wire data carries no
209
+ * `__case`), else the schema `default`. This lets a choice seed from real
210
+ * instance data whose branch is discriminated by which fields are present.
211
+ */
212
+ function resolveChoiceCase(choice, initial) {
213
+ const explicit = initial?.[CASE_KEY];
214
+ if (typeof explicit === 'string')
215
+ return explicit;
216
+ return inferChoiceCase(choice, initial) ?? choice.default;
217
+ }
218
+ /** Pick the case whose fields most overlap the initial data (none if nothing matches). */
219
+ function inferChoiceCase(choice, initial) {
220
+ if (!initial)
221
+ return undefined;
222
+ let best;
223
+ let bestScore = 0;
224
+ for (const name of Object.keys(choice.cases)) {
225
+ const fields = Object.keys(caseFields(choice.cases[name]));
226
+ let score = 0;
227
+ for (const field of fields)
228
+ if (field in initial)
229
+ score++;
230
+ if (score > bestScore) {
231
+ bestScore = score;
232
+ best = name;
233
+ }
234
+ }
235
+ return best;
236
+ }
101
237
  function buildChoiceControl(choice, initial) {
102
- const active = initial?.[CASE_KEY] ?? choice.default;
238
+ const active = resolveChoiceCase(choice, initial);
103
239
  const controls = { [CASE_KEY]: new FormControl(active ?? null) };
104
240
  if (active && choice.cases[active]) {
105
- const caseChildren = choice.cases[active];
241
+ const caseChildren = caseFields(choice.cases[active]);
106
242
  for (const key in caseChildren) {
107
243
  controls[key] = buildControl(caseChildren[key], initial?.[key]);
108
244
  }
109
245
  }
110
246
  return new FormGroup(controls);
111
247
  }
248
+ /**
249
+ * Switch a choice's FormGroup to `caseName`: sets `__case`, removes every other
250
+ * control, and builds `caseName`'s fields (normalized via {@link caseFields})
251
+ * with their defaults. An unknown case name leaves only `__case`.
252
+ */
253
+ function switchChoiceCase(group, choice, caseName) {
254
+ group.get(CASE_KEY)?.setValue(caseName);
255
+ for (const name of Object.keys(group.controls)) {
256
+ if (name !== CASE_KEY)
257
+ group.removeControl(name);
258
+ }
259
+ const caseChildren = choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {};
260
+ for (const name in caseChildren) {
261
+ group.addControl(name, buildControl(caseChildren[name]));
262
+ }
263
+ }
264
+ /**
265
+ * Append a map entry built from `map.value` and return its committed key, or
266
+ * `null` when nothing was added. With no `key`, the first free `keyN`
267
+ * placeholder is generated (not checked against `keyPattern` — placeholders are
268
+ * meant to be renamed). An explicit `key` is rejected when it duplicates an
269
+ * existing entry or violates `keyPattern`. Rejects when `maxEntries` is reached.
270
+ */
271
+ function addMapEntry(group, map, key) {
272
+ if (map.maxEntries != null && Object.keys(group.controls).length >= map.maxEntries)
273
+ return null;
274
+ let committed;
275
+ if (key != null) {
276
+ if (group.contains(key))
277
+ return null;
278
+ if (map.keyPattern && !new RegExp(map.keyPattern).test(key))
279
+ return null;
280
+ committed = key;
281
+ }
282
+ else {
283
+ let n = Object.keys(group.controls).length + 1;
284
+ committed = `key${n}`;
285
+ while (group.contains(committed))
286
+ committed = `key${++n}`;
287
+ }
288
+ group.addControl(committed, buildControl(map.value));
289
+ return committed;
290
+ }
291
+ /**
292
+ * Rename entry `oldKey` to `newKey.trim()`, preserving the control instance
293
+ * (remove + re-add, so the value survives). Returns whether the rename was
294
+ * committed: an empty, unchanged, duplicate, or `keyPattern`-violating key is a
295
+ * no-op, leaving the entry under its current name.
296
+ */
297
+ function renameMapEntry(group, map, oldKey, newKey) {
298
+ const committed = newKey.trim();
299
+ if (!committed || committed === oldKey || group.contains(committed))
300
+ return false;
301
+ if (map.keyPattern && !new RegExp(map.keyPattern).test(committed))
302
+ return false;
303
+ const control = group.get(oldKey);
304
+ if (!control)
305
+ return false;
306
+ group.removeControl(oldKey);
307
+ group.addControl(committed, control);
308
+ return true;
309
+ }
310
+ /** Remove entry `key` unless the map is at `minEntries`. Returns whether it was removed. */
311
+ function removeMapEntry(group, map, key) {
312
+ if (!group.contains(key))
313
+ return false;
314
+ if (map.minEntries != null && Object.keys(group.controls).length <= map.minEntries)
315
+ return false;
316
+ group.removeControl(key);
317
+ return true;
318
+ }
319
+ /**
320
+ * Build the FormGroup for a map: one control per entry, keyed by the entry key,
321
+ * each built from the map's shared `value` schema. Because the entry keys are the
322
+ * control names, `getRawValue()` is the map object directly. Empty when no
323
+ * initial object is supplied; the renderer adds/removes/renames entries.
324
+ */
325
+ function buildMapControl(map, initial) {
326
+ const controls = {};
327
+ const source = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
328
+ for (const key of Object.keys(source)) {
329
+ controls[key] = buildControl(map.value, source[key]);
330
+ }
331
+ return new FormGroup(controls);
332
+ }
112
333
  function buildControl(node, initial) {
113
334
  if (isLeaf(node)) {
114
335
  return buildLeafControl(node, initial);
@@ -116,6 +337,9 @@ function buildControl(node, initial) {
116
337
  if (isChoice(node)) {
117
338
  return buildChoiceControl(node, initial ? initial : null);
118
339
  }
340
+ if (isMap(node)) {
341
+ return buildMapControl(node, initial ? initial : null);
342
+ }
119
343
  if (isLeafList(node)) {
120
344
  return buildLeafListControl(node, initial !== null
121
345
  ? initial
@@ -144,17 +368,23 @@ function buildControl(node, initial) {
144
368
  * names and value types.
145
369
  */
146
370
  /**
147
- * Remove presence groups that have no initial value so they are absent from the
148
- * built form (and from `form.value`) until the user toggles them on. Runs on the
149
- * fully-built, attached tree. `removeControl` is used rather than `disable`
150
- * because a disabled group is still included in `FormGroup.value`.
371
+ * Remove presence nodes that have no initial value so they are absent from the
372
+ * built form (and from `form.value`) until the user toggles them on. Applies to
373
+ * presence groups, leaves, maps, and choices. Runs on the fully-built, attached
374
+ * tree. `removeControl` is used rather than `disable` because a disabled control
375
+ * is still included in `FormGroup.value`.
151
376
  */
152
377
  function applyPresence(group, schema, initial) {
153
378
  for (const key in schema.children) {
154
379
  const child = schema.children[key];
380
+ const childInitial = initial?.[key];
381
+ if (child.kind === 'leaf' || child.kind === 'map' || child.kind === 'choice') {
382
+ if (child.presence && childInitial == null)
383
+ group.removeControl(key);
384
+ continue;
385
+ }
155
386
  if (child.kind !== 'nodeGroup')
156
387
  continue;
157
- const childInitial = initial?.[key];
158
388
  if (child.presence && childInitial == null) {
159
389
  group.removeControl(key);
160
390
  }
@@ -213,19 +443,68 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
213
443
  }] } });
214
444
 
215
445
  class LeafRendererComponent {
446
+ elementRef;
216
447
  leaf_;
217
448
  control = new FormControl();
218
449
  initialValue;
219
450
  removable = false;
220
451
  editable = true;
452
+ /** Focus the field once rendered — for fields the user just added (e.g. an enabled presence leaf). */
453
+ autofocus = false;
221
454
  remove = new EventEmitter();
455
+ constructor(elementRef) {
456
+ this.elementRef = elementRef;
457
+ }
222
458
  ngOnInit() {
223
459
  if (this.initialValue) {
224
460
  this.control.patchValue(this.initialValue);
225
461
  }
226
462
  }
227
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
228
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafRendererComponent, isStandalone: true, selector: "nff-leaf-renderer", inputs: { leaf_: "leaf_", control: "control", initialValue: "initialValue", removable: "removable", editable: "editable" }, outputs: { remove: "remove" }, ngImport: i0, template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!editable\" matInput type=\"text\" [formControl]=\"control\">\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!editable\" matInput type=\"number\" [formControl]=\"control\">\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"editable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%}mat-form-field{flex:1;min-width:0}\n"], dependencies: [{ kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: LeafEnumRendererComponent, selector: "nff-leaf-enum-renderer", inputs: ["leafEnum", "initialValue", "control", "removable", "remove", "editable"] }] });
463
+ ngAfterViewInit() {
464
+ if (this.autofocus) {
465
+ // Deferred out of the change-detection pass: focusing flips the form
466
+ // field's label-float state, which would otherwise trip NG0100.
467
+ setTimeout(() => this.elementRef.nativeElement.querySelector('input, mat-select')?.focus());
468
+ }
469
+ }
470
+ /** Whether this field accepts input: the form is editable and the leaf is not `readOnly`. */
471
+ get fieldEditable() {
472
+ return this.editable && !('name' in this.leaf_ && this.leaf_.readOnly);
473
+ }
474
+ /**
475
+ * A human-readable message for the control's active validation error, or `''`
476
+ * when valid. `mat-form-field` only shows it once the field is in an error
477
+ * state (invalid and touched), so it can be bound unconditionally.
478
+ */
479
+ get errorText() {
480
+ const e = this.control.errors;
481
+ if (!e)
482
+ return '';
483
+ const label = 'name' in this.leaf_ ? this.leaf_.label ?? this.leaf_.name : 'Value';
484
+ if (e['required'])
485
+ return `${label} is required`;
486
+ if (e['minlength'])
487
+ return `Must be at least ${e['minlength'].requiredLength} characters`;
488
+ if (e['maxlength'])
489
+ return `Must be at most ${e['maxlength'].requiredLength} characters`;
490
+ if (e['pattern'])
491
+ return `Must match ${e['pattern'].requiredPattern ?? 'the required pattern'}`;
492
+ if (e['email'])
493
+ return 'Must be a valid email address';
494
+ if (e['uri'])
495
+ return 'Must be a valid URI';
496
+ if (e['min'])
497
+ return `Must be ≥ ${e['min'].min}`;
498
+ if (e['max'])
499
+ return `Must be ≤ ${e['max'].max}`;
500
+ if (e['multipleOf'])
501
+ return `Must be a multiple of ${e['multipleOf'].multipleOf}`;
502
+ if (e['enum'])
503
+ return 'Not an allowed value';
504
+ return 'Invalid value';
505
+ }
506
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafRendererComponent, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component });
507
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafRendererComponent, isStandalone: true, selector: "nff-leaf-renderer", inputs: { leaf_: "leaf_", control: "control", initialValue: "initialValue", removable: "removable", editable: "editable", autofocus: "autofocus" }, outputs: { remove: "remove" }, ngImport: i0, template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"text\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"number\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"fieldEditable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n @if (removable && editable) {\n <button matIconButton class=\"remove-button small-icon-button\" matTooltip=\"Remove\" (click)=\"remove.emit(0)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%;gap:8px}mat-form-field{flex:1;min-width:0}.remove-button{flex:0 0 auto;align-self:flex-start;margin-top:4px;background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"], dependencies: [{ kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "directive", type: i2.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i4$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: LeafEnumRendererComponent, selector: "nff-leaf-enum-renderer", inputs: ["leafEnum", "initialValue", "control", "removable", "remove", "editable"] }] });
229
508
  }
230
509
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafRendererComponent, decorators: [{
231
510
  type: Component,
@@ -237,9 +516,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
237
516
  MatSelectModule,
238
517
  MatIconModule,
239
518
  MatButtonModule,
519
+ MatTooltip,
240
520
  LeafEnumRendererComponent,
241
- ], template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!editable\" matInput type=\"text\" [formControl]=\"control\">\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!editable\" matInput type=\"number\" [formControl]=\"control\">\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"editable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%}mat-form-field{flex:1;min-width:0}\n"] }]
242
- }], propDecorators: { leaf_: [{
521
+ ], template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"text\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"number\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"fieldEditable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n @if (removable && editable) {\n <button matIconButton class=\"remove-button small-icon-button\" matTooltip=\"Remove\" (click)=\"remove.emit(0)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%;gap:8px}mat-form-field{flex:1;min-width:0}.remove-button{flex:0 0 auto;align-self:flex-start;margin-top:4px;background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"] }]
522
+ }], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { leaf_: [{
243
523
  type: Input
244
524
  }], control: [{
245
525
  type: Input
@@ -249,6 +529,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
249
529
  type: Input
250
530
  }], editable: [{
251
531
  type: Input
532
+ }], autofocus: [{
533
+ type: Input
252
534
  }], remove: [{
253
535
  type: Output
254
536
  }] } });
@@ -298,7 +580,7 @@ class NodeGroupListRendererComponent {
298
580
  setLastEditable() {
299
581
  const lastItem = this.items.last;
300
582
  if (lastItem) {
301
- lastItem.editable = true;
583
+ lastItem.editable.set(true);
302
584
  }
303
585
  this.cdr.detectChanges();
304
586
  }
@@ -315,7 +597,7 @@ class NodeGroupListRendererComponent {
315
597
  }
316
598
  }
317
599
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeGroupListRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
318
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeGroupListRendererComponent, isStandalone: true, selector: "nff-node-group-list-renderer", inputs: { nodeGroupList: "nodeGroupList", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems", maxItems: "maxItems" }, outputs: { message: "message" }, viewQueries: [{ propertyName: "items", predicate: i0.forwardRef(() => DynamicRecursiveFormComponent), descendants: true }], ngImport: i0, template: "@if (formArray && nodeGroupList) {\n <div class=\"fields-container\">\n @for (group of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-dynamic-recursive-form\n [schema]=\"nodeGroupList.type\"\n [formGroup]=\"asFormGroup(group)\"\n [title]=\"getTitle($index)\"\n [index]=\"0\"\n [removable]=\"formArray.length > minItems\"\n (remove)=\"removeItem($index)\"\n [editable]=\"editable\"\n [addButtonCallback]=\"addItem\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{position:relative;width:100%;flex:1 1 0}.fields{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.fields-container{display:flex;flex-direction:column}.field-item{display:flex;flex-direction:row;gap:8px}.actions{display:flex;flex-direction:row;gap:8px;align-items:center;margin-top:0;padding:0;min-width:24px}.title{display:flex;flex-direction:column;gap:8px;align-items:center}nff-dynamic-recursive-form{flex:1}.add-button{background-color:var(--mat-sys-primary-container)}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatTooltipModule) }] });
600
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeGroupListRendererComponent, isStandalone: true, selector: "nff-node-group-list-renderer", inputs: { nodeGroupList: "nodeGroupList", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems", maxItems: "maxItems" }, outputs: { message: "message" }, viewQueries: [{ propertyName: "items", predicate: i0.forwardRef(() => DynamicRecursiveFormComponent), descendants: true }], ngImport: i0, template: "@if (formArray && nodeGroupList) {\n <div class=\"fields-container\">\n @for (group of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-dynamic-recursive-form\n [schema]=\"nodeGroupList.type\"\n [formGroup]=\"asFormGroup(group)\"\n [title]=\"getTitle($index)\"\n [index]=\"0\"\n [removable]=\"formArray.length > minItems\"\n (remove)=\"removeItem($index)\"\n [editable]=\"editable\"\n [addButtonCallback]=\"addItem\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{position:relative;width:100%;flex:1 1 0}.fields{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.fields-container{display:flex;flex-direction:column}.field-item{display:flex;flex-direction:row;gap:8px}.actions{display:flex;flex-direction:row;gap:8px;align-items:center;margin-top:0;padding:0;min-width:24px}.title{display:flex;flex-direction:column;gap:8px;align-items:center}nff-dynamic-recursive-form{flex:1}.add-button{background-color:var(--mat-sys-primary-container)}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove", "editableChange"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatTooltipModule) }] });
319
601
  }
320
602
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeGroupListRendererComponent, decorators: [{
321
603
  type: Component,
@@ -424,7 +706,7 @@ class LeafListRendererComponent {
424
706
  this.formArray.push(new FormControl());
425
707
  }
426
708
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafListRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
427
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafListRendererComponent, isStandalone: true, selector: "nff-leaf-list-renderer", inputs: { leaf_: "leaf_", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems" }, outputs: { message: "message" }, host: { properties: { "class.stacked": "this.stacked" } }, ngImport: i0, template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: AnonLeafRendererComponent, selector: "nff-anon-leaf-renderer", inputs: ["AnonLeaf", "initialValue", "control", "removable", "editable", "index", "label"], outputs: ["remove"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }] });
709
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafListRendererComponent, isStandalone: true, selector: "nff-leaf-list-renderer", inputs: { leaf_: "leaf_", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems" }, outputs: { message: "message" }, host: { properties: { "class.stacked": "this.stacked" } }, ngImport: i0, template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: AnonLeafRendererComponent, selector: "nff-anon-leaf-renderer", inputs: ["AnonLeaf", "initialValue", "control", "removable", "editable", "index", "label"], outputs: ["remove"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i4$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }] });
428
710
  }
429
711
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafListRendererComponent, decorators: [{
430
712
  type: Component,
@@ -492,25 +774,113 @@ function asFormGroup(control) {
492
774
  return control;
493
775
  }
494
776
 
495
- class DynamicRecursiveFormComponent {
496
- schema;
497
- initialValue;
777
+ /**
778
+ * Renders a {@link NodeMap}: an open, arbitrary-keyed record. Each entry is a row
779
+ * with an editable key and the shared value schema's control; the user can add,
780
+ * remove, and rename entries. The map's control is a `FormGroup` whose control
781
+ * names are the entry keys, so the key is edited as a *rename* (remove + re-add
782
+ * the same control) committed on blur — see {@link renameEntry}.
783
+ */
784
+ class NodeMapRendererComponent {
785
+ nodeMap;
498
786
  formGroup = new FormGroup({});
499
- index = null;
500
- removable = false;
501
- remove = new EventEmitter();
502
- title;
503
- editable = false;
504
- addButtonCallback = null;
505
- root = false;
787
+ editable = true;
788
+ /** Ordered view of entry keys, kept stable across renames (`addControl` appends). */
789
+ entryKeys = [];
506
790
  ngOnInit() {
507
- this.root = this.schema.root ?? false;
508
- if (this.initialValue) {
509
- this.formGroup.patchValue(this.initialValue);
791
+ this.entryKeys = Object.keys(this.formGroup.controls);
792
+ }
793
+ /** The value schema as a leaf (used when `value.kind === 'leaf'`). */
794
+ get valueLeaf() {
795
+ return this.nodeMap.value;
796
+ }
797
+ /** The value schema as a group (used when `value.kind === 'nodeGroup'`). */
798
+ get valueGroup() {
799
+ return this.nodeMap.value;
800
+ }
801
+ get atMax() {
802
+ return this.nodeMap.maxEntries != null && this.entryKeys.length >= this.nodeMap.maxEntries;
803
+ }
804
+ get atMin() {
805
+ return this.nodeMap.minEntries != null && this.entryKeys.length <= this.nodeMap.minEntries;
806
+ }
807
+ /** Append a new entry under a unique placeholder key. Delegates to {@link addMapEntry}. */
808
+ addEntry() {
809
+ const key = addMapEntry(this.formGroup, this.nodeMap);
810
+ if (key != null)
811
+ this.entryKeys = [...this.entryKeys, key];
812
+ }
813
+ /** Drop an entry (its control leaves the group, so it drops from the value). Delegates to {@link removeMapEntry}. */
814
+ removeEntry(key) {
815
+ if (removeMapEntry(this.formGroup, this.nodeMap, key)) {
816
+ this.entryKeys = this.entryKeys.filter((k) => k !== key);
817
+ }
818
+ }
819
+ /**
820
+ * Commit an edited key by renaming its control once (the value is preserved).
821
+ * An empty, unchanged, duplicate, or `keyPattern`-violating key is a no-op,
822
+ * leaving the entry under its current name. Delegates to {@link renameMapEntry}.
823
+ */
824
+ renameEntry(oldKey, rawKey) {
825
+ if (renameMapEntry(this.formGroup, this.nodeMap, oldKey, rawKey)) {
826
+ const newKey = rawKey.trim();
827
+ this.entryKeys = this.entryKeys.map((k) => (k === oldKey ? newKey : k));
828
+ }
829
+ }
830
+ asFormControl = asFormControl;
831
+ asFormGroup = asFormGroup;
832
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeMapRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
833
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeMapRendererComponent, isStandalone: true, selector: "nff-node-map-renderer", inputs: { nodeMap: "nodeMap", formGroup: "formGroup", editable: "editable" }, ngImport: i0, template: "@if (formGroup && nodeMap) {\n <div class=\"map-node\">\n @for (key of entryKeys; track key) {\n <div class=\"map-entry\">\n <mat-form-field class=\"key-field\" [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ nodeMap.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #keyInput\n [readonly]=\"!editable\"\n [value]=\"key\"\n (change)=\"renameEntry(key, keyInput.value)\"\n >\n </mat-form-field>\n\n <div class=\"value-field\">\n @if (nodeMap.value.kind === 'leaf') {\n <nff-leaf-renderer\n [leaf_]=\"valueLeaf\"\n [control]=\"asFormControl(formGroup.get(key))\"\n [editable]=\"editable\"\n />\n } @else if (nodeMap.value.kind === 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"valueGroup\"\n [formGroup]=\"asFormGroup(formGroup.get(key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n\n @if (editable && !atMin) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeEntry(key)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n }\n @if (editable && !atMax) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addEntry()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n </div>\n}\n", styles: [":host{display:flex;flex:1 1 100%}.map-node{display:flex;flex-direction:column;gap:8px;width:100%}.map-title{font:var(--mat-sys-title-small);color:var(--mat-sys-on-surface-variant)}.map-entry{display:flex;flex-direction:row;gap:8px;align-items:flex-start}.key-field{flex:0 0 30%;min-width:120px}.value-field{display:flex;flex:1 1 0;min-width:0}.add-button{align-self:flex-start;background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "component", type: i0.forwardRef(() => i4$1.MatIconButton), selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "component", type: i0.forwardRef(() => i2$1.MatIcon), selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatFormFieldModule) }, { kind: "component", type: i0.forwardRef(() => i2.MatFormField), selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i0.forwardRef(() => i2.MatLabel), selector: "mat-label" }, { kind: "ngmodule", type: i0.forwardRef(() => MatInputModule) }, { kind: "directive", type: i0.forwardRef(() => i3$1.MatInput), selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "component", type: i0.forwardRef(() => LeafRendererComponent), selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable", "autofocus"], outputs: ["remove"] }, { kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove", "editableChange"] }] });
834
+ }
835
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeMapRendererComponent, decorators: [{
836
+ type: Component,
837
+ args: [{ selector: 'nff-node-map-renderer', standalone: true, imports: [
838
+ MatButtonModule,
839
+ MatIconModule,
840
+ MatFormFieldModule,
841
+ MatInputModule,
842
+ LeafRendererComponent,
843
+ // node-map-renderer and dynamic-recursive-form import each other (a map value
844
+ // may be a group); forwardRef defers the reference to break the cycle.
845
+ forwardRef(() => DynamicRecursiveFormComponent),
846
+ ], template: "@if (formGroup && nodeMap) {\n <div class=\"map-node\">\n @for (key of entryKeys; track key) {\n <div class=\"map-entry\">\n <mat-form-field class=\"key-field\" [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ nodeMap.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #keyInput\n [readonly]=\"!editable\"\n [value]=\"key\"\n (change)=\"renameEntry(key, keyInput.value)\"\n >\n </mat-form-field>\n\n <div class=\"value-field\">\n @if (nodeMap.value.kind === 'leaf') {\n <nff-leaf-renderer\n [leaf_]=\"valueLeaf\"\n [control]=\"asFormControl(formGroup.get(key))\"\n [editable]=\"editable\"\n />\n } @else if (nodeMap.value.kind === 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"valueGroup\"\n [formGroup]=\"asFormGroup(formGroup.get(key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n\n @if (editable && !atMin) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeEntry(key)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n }\n @if (editable && !atMax) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addEntry()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n </div>\n}\n", styles: [":host{display:flex;flex:1 1 100%}.map-node{display:flex;flex-direction:column;gap:8px;width:100%}.map-title{font:var(--mat-sys-title-small);color:var(--mat-sys-on-surface-variant)}.map-entry{display:flex;flex-direction:row;gap:8px;align-items:flex-start}.key-field{flex:0 0 30%;min-width:120px}.value-field{display:flex;flex:1 1 0;min-width:0}.add-button{align-self:flex-start;background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"] }]
847
+ }], propDecorators: { nodeMap: [{
848
+ type: Input
849
+ }], formGroup: [{
850
+ type: Input
851
+ }], editable: [{
852
+ type: Input
853
+ }] } });
854
+
855
+ class DynamicRecursiveFormComponent {
856
+ /** The form-description schema to render (a root or nested `NodeGroup`). */
857
+ schema = input.required(...(ngDevMode ? [{ debugName: "schema" }] : []));
858
+ /** Optional value object to seed the form; keyed by the schema's `children` keys. */
859
+ initialValue = input(null, ...(ngDevMode ? [{ debugName: "initialValue" }] : []));
860
+ /** The reactive group this form binds to. Defaults to an empty group. */
861
+ formGroup = input(new FormGroup({}), ...(ngDevMode ? [{ debugName: "formGroup" }] : []));
862
+ /** Index of this form within a parent list, used by `addButtonCallback`. */
863
+ index = input(null, ...(ngDevMode ? [{ debugName: "index" }] : []));
864
+ /** Whether this form may be removed from a parent list (shows a remove control). */
865
+ removable = input(false, ...(ngDevMode ? [{ debugName: "removable" }] : []));
866
+ /** Emitted when the user removes this form from a parent list. */
867
+ remove = output();
868
+ /** Card/section title; falls back to the schema label or name. */
869
+ title = input(...(ngDevMode ? [undefined, { debugName: "title" }] : []));
870
+ /** Whether fields accept input. Two-way: also toggled by the built-in edit control. */
871
+ editable = model(false, ...(ngDevMode ? [{ debugName: "editable" }] : []));
872
+ /** Invoked with {@link index} to append a new sibling form to a parent list. */
873
+ addButtonCallback = input(null, ...(ngDevMode ? [{ debugName: "addButtonCallback" }] : []));
874
+ /** True when the schema is a root group (rendered flat, without a wrapping card). */
875
+ root = computed(() => this.schema().root ?? false, ...(ngDevMode ? [{ debugName: "root" }] : []));
876
+ ngOnInit() {
877
+ const initial = this.initialValue();
878
+ if (initial) {
879
+ this.formGroup().patchValue(initial);
510
880
  }
511
881
  }
512
882
  get nodeGroupChildrenList() {
513
- const children = this.schema.children ?? {};
883
+ const children = this.schema().children ?? {};
514
884
  return Object.entries(children).map(([key, value]) => ({
515
885
  key,
516
886
  value: value,
@@ -524,13 +894,51 @@ class DynamicRecursiveFormComponent {
524
894
  * group from `form.value`; adding it rebuilds the sub-group from its schema.
525
895
  */
526
896
  togglePresence(key, schema, present) {
897
+ const group = this.formGroup();
898
+ if (present) {
899
+ if (!group.get(key)) {
900
+ group.addControl(key, buildFormFromSchema(schema));
901
+ }
902
+ }
903
+ else if (group.get(key)) {
904
+ group.removeControl(key);
905
+ }
906
+ }
907
+ /** The key of the presence leaf the user just enabled; its field grabs focus when rendered. */
908
+ presenceFocusKey = null;
909
+ /**
910
+ * Add or remove a presence leaf's control on this form. Removing it drops the
911
+ * leaf from `form.value`; adding it rebuilds the control from its schema and
912
+ * focuses the rendered field.
913
+ */
914
+ toggleLeafPresence(key, schema, present) {
915
+ const group = this.formGroup();
527
916
  if (present) {
528
- if (!this.formGroup.get(key)) {
529
- this.formGroup.addControl(key, buildFormFromSchema(schema));
917
+ if (!group.get(key)) {
918
+ group.addControl(key, buildControl(schema));
919
+ this.presenceFocusKey = key;
530
920
  }
531
921
  }
532
- else if (this.formGroup.get(key)) {
533
- this.formGroup.removeControl(key);
922
+ else if (group.get(key)) {
923
+ group.removeControl(key);
924
+ if (this.presenceFocusKey === key)
925
+ this.presenceFocusKey = null;
926
+ }
927
+ }
928
+ /**
929
+ * Add or remove a presence map's or choice's control on this form. Removing it
930
+ * drops the node from `form.value`; adding it rebuilds the control (an empty
931
+ * map, or a choice group holding `__case`) from its schema.
932
+ */
933
+ toggleNodePresence(key, schema, present) {
934
+ const group = this.formGroup();
935
+ if (present) {
936
+ if (!group.get(key)) {
937
+ group.addControl(key, buildControl(schema));
938
+ }
939
+ }
940
+ else if (group.get(key)) {
941
+ group.removeControl(key);
534
942
  }
535
943
  }
536
944
  CASE_KEY = CASE_KEY;
@@ -539,41 +947,49 @@ class DynamicRecursiveFormComponent {
539
947
  }
540
948
  /** The active case name of a choice control, or null if none is selected. */
541
949
  activeCase(key) {
542
- return this.formGroup.get(key)?.get(CASE_KEY)?.value ?? null;
950
+ return this.formGroup().get(key)?.get(CASE_KEY)?.value ?? null;
543
951
  }
544
952
  /** A synthetic flattened NodeGroup used to render the active case's fields against the choice's FormGroup. */
545
953
  caseAsGroup(choice, caseName) {
546
954
  return {
547
955
  kind: 'nodeGroup',
548
956
  name: choice.name,
549
- children: choice.cases[caseName] ?? {},
957
+ children: choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {},
550
958
  appearance: { flatten: true },
551
959
  };
552
960
  }
553
- /** Swap a choice's field controls when the selected case changes. */
961
+ /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
962
+ caseLabel(choice, caseName) {
963
+ return choice.caseLabels?.[caseName] ?? caseName;
964
+ }
965
+ /**
966
+ * A copy of a group flagged to render its fields inline (no inner panel). Used
967
+ * for a presence group's body, whose container is the presence panel itself, so
968
+ * the group's own section panel would be a redundant second box.
969
+ */
970
+ flatGroup(group) {
971
+ return { ...group, appearance: { ...group.appearance, flatten: true } };
972
+ }
973
+ /** Swap a choice's field controls when the selected case changes. Delegates to {@link switchChoiceCase}. */
554
974
  switchCase(key, choice, caseName) {
555
- const group = this.formGroup.get(key);
556
- for (const name of Object.keys(group.controls)) {
557
- if (name !== CASE_KEY)
558
- group.removeControl(name);
559
- }
560
- const caseChildren = choice.cases[caseName] ?? {};
561
- for (const name in caseChildren) {
562
- group.addControl(name, buildControl(caseChildren[name]));
563
- }
975
+ switchChoiceCase(this.formGroup().get(key), choice, caseName);
564
976
  }
565
977
  asFormGroup = asFormGroup;
566
978
  asFormArray = asFormArray;
567
979
  asFormControl = asFormControl;
568
980
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: DynamicRecursiveFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
569
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: DynamicRecursiveFormComponent, isStandalone: true, selector: "nff-dynamic-recursive-form", inputs: { schema: "schema", initialValue: "initialValue", formGroup: "formGroup", index: "index", removable: "removable", title: "title", editable: "editable", addButtonCallback: "addButtonCallback" }, outputs: { remove: "remove" }, ngImport: i0, template: "@if (!root) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n }\n </div>\n </ng-template>\n @if (schema.appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-card appearance=\"outlined\" [class]=\"{ 'mat-card-no-broder': schema.appearance?.noBorder }\">\n <mat-card-header>\n <div class=\"card-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable}\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n <mat-card-title class=\"config-form-subsection-card\">\n {{ title ?? schema.label ?? schema.name }}\n </mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-card-content>\n </mat-card>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable }\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n </div>\n </div>\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup.get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <mat-expansion-panel togglePosition=\"before\" expanded=\"true\">\n <mat-expansion-panel-header >\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [title]=\"child.name ?? 'General Settings'\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"], dependencies: [{ kind: "component", type: DynamicRecursiveFormComponent, selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove"] }, { kind: "component", type: LeafRendererComponent, selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable"], outputs: ["remove"] }, { kind: "component", type: NodeGroupListRendererComponent, selector: "nff-node-group-list-renderer", inputs: ["nodeGroupList", "initialValue", "formArray", "editable", "minItems", "maxItems"], outputs: ["message"] }, { kind: "component", type: LeafListRendererComponent, selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatExpansionModule }, { kind: "directive", type: i2$1.MatAccordion, selector: "mat-accordion", inputs: ["hideToggle", "displayMode", "togglePosition"], exportAs: ["matAccordion"] }, { kind: "component", type: i2$1.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i2$1.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i2$1.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i5.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i5.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i5.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i5.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
981
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: DynamicRecursiveFormComponent, isStandalone: true, selector: "nff-dynamic-recursive-form", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: true, transformFunction: null }, initialValue: { classPropertyName: "initialValue", publicName: "initialValue", isSignal: true, isRequired: false, transformFunction: null }, formGroup: { classPropertyName: "formGroup", publicName: "formGroup", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null }, removable: { classPropertyName: "removable", publicName: "removable", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, addButtonCallback: { classPropertyName: "addButtonCallback", publicName: "addButtonCallback", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { remove: "remove", editable: "editableChange" }, ngImport: i0, template: "@if (!root()) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <!-- Present: the field takes the add button's place in the row; its\n remove control drops the leaf back to the unchecked state. -->\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [autofocus]=\"presenceFocusKey === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n [disabled]=\"!editable()\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n } @else if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(child.name))\n [removable]=false\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n </div>\n </ng-template>\n @if (schema().appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-expansion-panel class=\"node-section\" [expanded]=\"!schema().appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title class=\"config-form-subsection-card\">\n {{ title() ?? schema().label ?? schema().name }}\n </mat-panel-title>\n <mat-panel-description class=\"section-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" (click)=\"editable.set(!editable()); $event.stopPropagation()\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!); $event.stopPropagation()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </mat-panel-description>\n </mat-expansion-panel-header>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-expansion-panel>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" (click)=\"editable.set(!editable())\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <!-- Present: the field takes the add button's place in the row; its\n remove control drops the leaf back to the unchecked state. -->\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [autofocus]=\"presenceFocusKey === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n [disabled]=\"!editable()\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n } @else if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(child.name))\n [removable]=false\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title() ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup().get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.node-section{width:100%;margin:4px 0}.section-actions{justify-content:flex-end;align-items:center;gap:4px}.presence-leaf-add{flex:0 1 auto;align-self:flex-start;margin-top:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove", "editableChange"] }, { kind: "component", type: i0.forwardRef(() => LeafRendererComponent), selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable", "autofocus"], outputs: ["remove"] }, { kind: "component", type: i0.forwardRef(() => LeafListRendererComponent), selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }, { kind: "component", type: i0.forwardRef(() => NodeGroupListRendererComponent), selector: "nff-node-group-list-renderer", inputs: ["nodeGroupList", "initialValue", "formArray", "editable", "minItems", "maxItems"], outputs: ["message"] }, { kind: "component", type: i0.forwardRef(() => NodeMapRendererComponent), selector: "nff-node-map-renderer", inputs: ["nodeMap", "formGroup", "editable"] }, { kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "directive", type: i0.forwardRef(() => i1.NgControlStatus), selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i0.forwardRef(() => i1.NgControlStatusGroup), selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i0.forwardRef(() => i1.FormGroupDirective), selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i0.forwardRef(() => i1.FormControlName), selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatExpansionModule) }, { kind: "directive", type: i0.forwardRef(() => i2$2.MatAccordion), selector: "mat-accordion", inputs: ["hideToggle", "displayMode", "togglePosition"], exportAs: ["matAccordion"] }, { kind: "component", type: i0.forwardRef(() => i2$2.MatExpansionPanel), selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i0.forwardRef(() => i2$2.MatExpansionPanelHeader), selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i0.forwardRef(() => i2$2.MatExpansionPanelTitle), selector: "mat-panel-title" }, { kind: "directive", type: i0.forwardRef(() => i2$2.MatExpansionPanelDescription), selector: "mat-panel-description" }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "component", type: i0.forwardRef(() => i2$1.MatIcon), selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "component", type: i0.forwardRef(() => i4$1.MatButton), selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i0.forwardRef(() => i4$1.MatIconButton), selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: i0.forwardRef(() => NgTemplateOutlet), selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatCardModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatCheckboxModule) }, { kind: "component", type: i0.forwardRef(() => i4.MatCheckbox), selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatFormFieldModule) }, { kind: "component", type: i0.forwardRef(() => i2.MatFormField), selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i0.forwardRef(() => i2.MatLabel), selector: "mat-label" }, { kind: "ngmodule", type: i0.forwardRef(() => MatSelectModule) }, { kind: "component", type: i0.forwardRef(() => i3.MatSelect), selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i0.forwardRef(() => i3.MatOption), selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i0.forwardRef(() => MatTooltip), selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
570
982
  }
571
983
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: DynamicRecursiveFormComponent, decorators: [{
572
984
  type: Component,
573
985
  args: [{ imports: [
574
986
  LeafRendererComponent,
575
- NodeGroupListRendererComponent,
576
987
  LeafListRendererComponent,
988
+ // node-group-list-renderer and node-map-renderer import this component back
989
+ // (list items and map values may be groups); forwardRef tolerates either
990
+ // module-evaluation order for the cycle.
991
+ forwardRef(() => NodeGroupListRendererComponent),
992
+ forwardRef(() => NodeMapRendererComponent),
577
993
  ReactiveFormsModule,
578
994
  MatExpansionModule,
579
995
  MatIconModule,
@@ -584,52 +1000,46 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
584
1000
  MatFormFieldModule,
585
1001
  MatSelectModule,
586
1002
  MatTooltip,
587
- ], selector: 'nff-dynamic-recursive-form', standalone: true, template: "@if (!root) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n }\n </div>\n </ng-template>\n @if (schema.appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-card appearance=\"outlined\" [class]=\"{ 'mat-card-no-broder': schema.appearance?.noBorder }\">\n <mat-card-header>\n <div class=\"card-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable}\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n <mat-card-title class=\"config-form-subsection-card\">\n {{ title ?? schema.label ?? schema.name }}\n </mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-card-content>\n </mat-card>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable }\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n </div>\n </div>\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup.get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <mat-expansion-panel togglePosition=\"before\" expanded=\"true\">\n <mat-expansion-panel-header >\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [title]=\"child.name ?? 'General Settings'\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"] }]
588
- }], propDecorators: { schema: [{
589
- type: Input,
590
- args: [{ required: true }]
591
- }], initialValue: [{
592
- type: Input
593
- }], formGroup: [{
594
- type: Input
595
- }], index: [{
596
- type: Input
597
- }], removable: [{
598
- type: Input
599
- }], remove: [{
600
- type: Output
601
- }], title: [{
602
- type: Input
603
- }], editable: [{
604
- type: Input
605
- }], addButtonCallback: [{
606
- type: Input
607
- }] } });
1003
+ ], selector: 'nff-dynamic-recursive-form', standalone: true, template: "@if (!root()) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <!-- Present: the field takes the add button's place in the row; its\n remove control drops the leaf back to the unchecked state. -->\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [autofocus]=\"presenceFocusKey === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n [disabled]=\"!editable()\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n } @else if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(child.name))\n [removable]=false\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n </div>\n </ng-template>\n @if (schema().appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-expansion-panel class=\"node-section\" [expanded]=\"!schema().appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title class=\"config-form-subsection-card\">\n {{ title() ?? schema().label ?? schema().name }}\n </mat-panel-title>\n <mat-panel-description class=\"section-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" (click)=\"editable.set(!editable()); $event.stopPropagation()\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!); $event.stopPropagation()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </mat-panel-description>\n </mat-expansion-panel-header>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-expansion-panel>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" (click)=\"editable.set(!editable())\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <!-- Present: the field takes the add button's place in the row; its\n remove control drops the leaf back to the unchecked state. -->\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [autofocus]=\"presenceFocusKey === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n [disabled]=\"!editable()\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n } @else if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(child.name))\n [removable]=false\n [editable]=\"editable()\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title() ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup().get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(child.name))\"\n [initialValue]=\"formGroup().get(child.name)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.node-section{width:100%;margin:4px 0}.section-actions{justify-content:flex-end;align-items:center;gap:4px}.presence-leaf-add{flex:0 1 auto;align-self:flex-start;margin-top:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"] }]
1004
+ }], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: true }] }], initialValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialValue", required: false }] }], formGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "formGroup", required: false }] }], index: [{ type: i0.Input, args: [{ isSignal: true, alias: "index", required: false }] }], removable: [{ type: i0.Input, args: [{ isSignal: true, alias: "removable", required: false }] }], remove: [{ type: i0.Output, args: ["remove"] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }, { type: i0.Output, args: ["editableChange"] }], addButtonCallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "addButtonCallback", required: false }] }] } });
608
1005
 
609
1006
  /**
610
- * A tree/detail editor for a schema-built form: the structure (containers, lists,
611
- * groups) is a tree on the left, and selecting a node shows that node's leaf
612
- * fields for editing on the right. A `+` on a list node adds an entry; a delete
613
- * button on each item removes it. An alternative to the all-in-one
614
- * {@link DynamicRecursiveFormComponent} for large configs.
1007
+ * A tree/detail editor for a schema-built form: the structure (groups, lists,
1008
+ * maps, choices) is a tree on the left, and selecting a node shows that node's
1009
+ * fields for editing on the right. A `+` on a list or map node adds an entry; a
1010
+ * delete button on each item removes it. Absent optional (presence) children are
1011
+ * offered by a "+ Optional field" menu row at the end of their parent's
1012
+ * children; present ones carry a delete button that returns them to the menu.
1013
+ *
1014
+ * The component draws no outer container — only a divider between the tree and
1015
+ * detail panes — so the embedding client owns the surrounding chrome. The tree
1016
+ * is built once from the `schema`/`formGroup` provided at initialization.
1017
+ * An alternative to the all-in-one {@link DynamicRecursiveFormComponent} for
1018
+ * large configs.
615
1019
  */
616
1020
  class ConfigEditorComponent {
617
- schema;
618
- formGroup;
619
- editable = true;
1021
+ /** The form-description schema whose structure the tree renders. */
1022
+ schema = input.required(...(ngDevMode ? [{ debugName: "schema" }] : []));
1023
+ /** The schema-built reactive group the editor binds to and mutates. */
1024
+ formGroup = input.required(...(ngDevMode ? [{ debugName: "formGroup" }] : []));
1025
+ /** Whether fields accept input and structural controls (add/remove/menus) show. */
1026
+ editable = input(true, ...(ngDevMode ? [{ debugName: "editable" }] : []));
620
1027
  root;
621
1028
  selected = null;
622
1029
  expanded = new Set();
623
1030
  nextId = 0;
624
1031
  ngOnInit() {
625
- this.root = this.buildTree(this.schema, this.formGroup, this.schema.label ?? this.schema.name);
1032
+ const schema = this.schema();
1033
+ this.root = this.buildTree(schema, this.formGroup(), schema.label ?? schema.name);
626
1034
  this.expanded.add(this.root.id);
627
1035
  this.select(this.root);
628
1036
  }
629
1037
  select(node) {
630
1038
  this.selected = node;
631
- // Reveal the node's direct children in the tree as it opens on the right.
632
- if (node.children.length)
1039
+ // Reveal the node's rows in the tree as it opens on the right. Navigating
1040
+ // also retires any pending just-added-leaf focus request.
1041
+ this.focusLeafKey = null;
1042
+ if (this.hasExpandableContent(node))
633
1043
  this.expanded.add(node.id);
634
1044
  }
635
1045
  /** Select a child (list item or sub-group) from the detail pane, keeping its parent expanded in the tree. */
@@ -662,6 +1072,18 @@ class ConfigEditorComponent {
662
1072
  else
663
1073
  this.expanded.add(node.id);
664
1074
  }
1075
+ /** Whether the row shows an expand twisty: it has child rows to reveal (children or an optionals menu row). */
1076
+ hasExpandableContent(node) {
1077
+ return node.children.length > 0 || (this.editable() && !!node.optionals?.length);
1078
+ }
1079
+ /**
1080
+ * Whether the node's form subtree holds a validation error. Group/choice, list,
1081
+ * and map nodes each check their backing control; `invalid` aggregates over
1082
+ * descendants, so an error anywhere below lights every ancestor row.
1083
+ */
1084
+ hasError(node) {
1085
+ return !!(node.group?.invalid || node.list?.array.invalid || node.map?.group.invalid);
1086
+ }
665
1087
  /** Append a new item to a list node's FormArray and to the tree, then select it. */
666
1088
  addItem(listNode) {
667
1089
  const list = listNode.list;
@@ -685,40 +1107,130 @@ class ConfigEditorComponent {
685
1107
  listNode.list.array.removeAt(item.removable.index);
686
1108
  listNode.children.splice(listNode.children.indexOf(item), 1);
687
1109
  this.renumber(listNode);
688
- if (this.selected === item)
689
- this.select(listNode);
1110
+ this.reselectIfOrphaned(listNode);
690
1111
  }
691
- /** Enable or disable an optional (presence) group by adding/removing its control. */
692
- setPresence(node, present) {
693
- const p = node.presence;
694
- if (!p)
1112
+ /** The key of the optional leaf the user just added; its detail-pane field grabs focus when rendered. */
1113
+ focusLeafKey = null;
1114
+ /** Add an absent optional child from the menu: build its control, place it in the tree, and select it. */
1115
+ addOptional(node, entry) {
1116
+ if (!node.group || node.group.get(entry.key))
695
1117
  return;
696
- if (present) {
697
- if (!(p.parentGroup.get(p.key) instanceof FormGroup)) {
698
- const group = buildFormFromSchema(p.schema);
699
- p.parentGroup.addControl(p.key, group);
700
- const built = this.buildTree(p.schema, group, node.label);
701
- node.children = built.children;
702
- node.leaves = built.leaves;
703
- node.leafLists = built.leafLists;
704
- node.group = group;
705
- }
706
- node.present = true;
1118
+ const control = entry.schema.kind === 'nodeGroup'
1119
+ ? buildFormFromSchema(entry.schema)
1120
+ : buildControl(entry.schema);
1121
+ node.group.addControl(entry.key, control);
1122
+ node.optionals = node.optionals?.filter((o) => o !== entry);
1123
+ if (entry.schema.kind === 'leaf') {
1124
+ node.leaves.push({ key: entry.key, node: entry.schema, optional: entry });
707
1125
  this.select(node);
1126
+ this.focusLeafKey = entry.key;
1127
+ return;
708
1128
  }
709
- else {
710
- p.parentGroup.removeControl(p.key);
711
- node.children = [];
712
- node.leaves = [];
713
- node.leafLists = [];
714
- node.group = null;
715
- node.present = false;
1129
+ const child = this.buildChildNode(entry.schema, control, entry.label);
1130
+ if (!child)
1131
+ return;
1132
+ child.presenceRemovable = { entry };
1133
+ node.children.push(child);
1134
+ this.expanded.add(node.id);
1135
+ this.select(child);
1136
+ }
1137
+ /** Remove a present optional child node, returning its entry to the parent's menu. */
1138
+ removeOptional(parent, node) {
1139
+ const entry = node.presenceRemovable?.entry;
1140
+ if (!entry || !parent.group)
1141
+ return;
1142
+ parent.group.removeControl(entry.key);
1143
+ parent.children.splice(parent.children.indexOf(node), 1);
1144
+ this.reinsertOptional(parent, entry);
1145
+ this.reselectIfOrphaned(parent);
1146
+ }
1147
+ /** Remove a present optional leaf from the detail pane, returning its entry to the menu. */
1148
+ removeOptionalLeaf(node, leaf) {
1149
+ if (!leaf.optional || !node.group)
1150
+ return;
1151
+ node.group.removeControl(leaf.key);
1152
+ node.leaves = node.leaves.filter((l) => l !== leaf);
1153
+ this.reinsertOptional(node, leaf.optional);
1154
+ }
1155
+ /** The active case name of a choice node, or null when none is selected. */
1156
+ activeCase(node) {
1157
+ return node.choice?.group.get(CASE_KEY)?.value ?? null;
1158
+ }
1159
+ /** The display label of a choice node's active case, or null when no case is selected. */
1160
+ activeCaseLabel(node) {
1161
+ const c = node.choice;
1162
+ const active = this.activeCase(node);
1163
+ return c && active ? this.caseLabel(c.schema, active) : null;
1164
+ }
1165
+ /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
1166
+ caseLabel(choice, caseName) {
1167
+ return choice.caseLabels?.[caseName] ?? caseName;
1168
+ }
1169
+ /** Switch a choice node's active case: swap the group's controls and rebuild the subtree in place. */
1170
+ switchTreeCase(node, caseName) {
1171
+ const c = node.choice;
1172
+ if (!c)
1173
+ return;
1174
+ switchChoiceCase(c.group, c.schema, caseName);
1175
+ const rebuilt = this.buildTree(this.caseAsGroup(c.schema, caseName), c.group, node.label);
1176
+ node.children = rebuilt.children;
1177
+ node.leaves = rebuilt.leaves;
1178
+ node.leafLists = rebuilt.leafLists;
1179
+ node.optionals = rebuilt.optionals;
1180
+ this.expanded.add(node.id);
1181
+ this.select(node);
1182
+ }
1183
+ /** Append a new entry to a complex map node under a generated unique key, then select it. */
1184
+ addTreeMapEntry(mapNode) {
1185
+ const m = mapNode.map;
1186
+ if (!m)
1187
+ return;
1188
+ const key = addMapEntry(m.group, m.schema);
1189
+ if (key == null)
1190
+ return;
1191
+ const child = this.buildChildNode(m.schema.value, m.group.get(key), key);
1192
+ if (!child)
1193
+ return;
1194
+ child.mapEntry = { mapGroup: m.group, mapSchema: m.schema, key };
1195
+ mapNode.children.push(child);
1196
+ this.expanded.add(mapNode.id);
1197
+ this.select(child);
1198
+ }
1199
+ /** Remove a complex map entry (down to `minEntries`) from the form group and the tree. */
1200
+ removeTreeMapEntry(mapNode, entryNode) {
1201
+ const m = mapNode.map;
1202
+ const e = entryNode.mapEntry;
1203
+ if (!m || !e || !removeMapEntry(m.group, m.schema, e.key))
1204
+ return;
1205
+ mapNode.children.splice(mapNode.children.indexOf(entryNode), 1);
1206
+ this.reselectIfOrphaned(mapNode);
1207
+ }
1208
+ /** Commit a rename-on-blur of a map entry's key; on success the node is relabeled. */
1209
+ renameTreeMapEntry(entryNode, rawKey) {
1210
+ const e = entryNode.mapEntry;
1211
+ if (!e)
1212
+ return;
1213
+ if (renameMapEntry(e.mapGroup, e.mapSchema, e.key, rawKey)) {
1214
+ const committed = rawKey.trim();
1215
+ e.key = committed;
1216
+ entryNode.label = committed;
716
1217
  }
717
1218
  }
1219
+ /** Whether a map node is at `maxEntries` (the add control is hidden). */
1220
+ mapAtMax(node) {
1221
+ const m = node?.map;
1222
+ return !!m && m.schema.maxEntries != null && Object.keys(m.group.controls).length >= m.schema.maxEntries;
1223
+ }
1224
+ /** Whether a map node is at `minEntries` (entry remove controls are hidden). */
1225
+ mapAtMin(node) {
1226
+ const m = node?.map;
1227
+ return !!m && m.schema.minEntries != null && Object.keys(m.group.controls).length <= m.schema.minEntries;
1228
+ }
718
1229
  asFormControl = asFormControl;
719
1230
  asFormArray = asFormArray;
720
- placeholder(label) {
721
- return { id: String(this.nextId++), label, children: [], leaves: [], leafLists: [], group: null };
1231
+ CASE_KEY = CASE_KEY;
1232
+ objectKeys(obj) {
1233
+ return Object.keys(obj);
722
1234
  }
723
1235
  /** Re-index and re-label a list node's item children (just "#n") after add/remove. */
724
1236
  renumber(listNode) {
@@ -728,65 +1240,142 @@ class ConfigEditorComponent {
728
1240
  child.label = `#${i + 1}`;
729
1241
  });
730
1242
  }
1243
+ /** Select `fallback` when the current selection is no longer reachable in the tree. */
1244
+ reselectIfOrphaned(fallback) {
1245
+ if (this.selected && this.pathTo(this.selected).length === 0)
1246
+ this.select(fallback);
1247
+ }
1248
+ /** Re-insert a removed optional's entry into the node's menu, keeping schema order. */
1249
+ reinsertOptional(node, entry) {
1250
+ const list = (node.optionals ??= []);
1251
+ const at = list.findIndex((o) => o.order > entry.order);
1252
+ if (at === -1)
1253
+ list.push(entry);
1254
+ else
1255
+ list.splice(at, 0, entry);
1256
+ }
1257
+ /** Synthetic group over a case's normalized fields, so a case body builds like any group. */
1258
+ caseAsGroup(choice, caseName) {
1259
+ return {
1260
+ kind: 'nodeGroup',
1261
+ name: choice.name,
1262
+ children: choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {},
1263
+ };
1264
+ }
731
1265
  buildTree(schema, group, label) {
732
1266
  const leaves = [];
733
1267
  const leafLists = [];
734
1268
  const children = [];
735
- for (const key of Object.keys(schema.children)) {
1269
+ const optionals = [];
1270
+ const keys = Object.keys(schema.children);
1271
+ keys.forEach((key, order) => {
736
1272
  const child = schema.children[key];
1273
+ const control = group.get(key);
1274
+ const entry = { key, schema: child, label: this.labelOf(child, key), order };
737
1275
  if (child.kind === 'leaf') {
738
- leaves.push({ key, node: child });
1276
+ if (child.presence && !control)
1277
+ optionals.push(entry);
1278
+ else if (control)
1279
+ leaves.push({ key, node: child, optional: child.presence ? entry : undefined });
1280
+ return;
739
1281
  }
740
- else if (child.kind === 'leafList') {
1282
+ if (child.kind === 'leafList') {
741
1283
  leafLists.push({ key, node: child });
1284
+ return;
742
1285
  }
743
- else if (child.kind === 'nodeGroup') {
744
- const childGroup = group.get(key);
745
- if (child.presence) {
746
- // Optional group: always a tree node — a placeholder when absent.
747
- const node = childGroup instanceof FormGroup
748
- ? this.buildTree(child, childGroup, child.label ?? key)
749
- : this.placeholder(child.label ?? key);
750
- node.presence = { parentGroup: group, key, schema: child };
751
- node.present = childGroup instanceof FormGroup;
752
- children.push(node);
753
- }
754
- else if (childGroup instanceof FormGroup) {
755
- children.push(this.buildTree(child, childGroup, child.label ?? key));
756
- }
1286
+ const presence = child.kind !== 'nodeGroupList' && child.presence;
1287
+ if (presence && !control) {
1288
+ optionals.push(entry);
1289
+ return;
757
1290
  }
758
- else if (child.kind === 'nodeGroupList') {
759
- const array = group.get(key);
760
- const itemLabel = child.type.label ?? child.type.name;
761
- const items = array instanceof FormArray
762
- ? array.controls
763
- .filter((c) => c instanceof FormGroup)
764
- .map((item, i) => {
765
- // Just "#n": the item sits under its list node, so repeating the
766
- // item name (e.g. "Interface #1") only echoes the parent.
767
- const node = this.buildTree(child.type, item, `#${i + 1}`);
768
- node.removable = { array, index: i };
769
- return node;
770
- })
771
- : [];
772
- children.push({
773
- id: String(this.nextId++),
774
- label: child.label ?? key,
775
- children: items,
776
- leaves: [],
777
- leafLists: [],
778
- group: null,
779
- list: array instanceof FormArray
780
- ? { array, itemSchema: child.type, itemLabel, minItems: child.minItems ?? 0 }
781
- : undefined,
782
- });
783
- }
784
- // choice nodes are not shown in the tree yet.
1291
+ const node = this.buildChildNode(child, control, entry.label);
1292
+ if (!node)
1293
+ return;
1294
+ if (presence)
1295
+ node.presenceRemovable = { entry };
1296
+ children.push(node);
1297
+ });
1298
+ const node = { id: String(this.nextId++), label, children, leaves, leafLists, group };
1299
+ if (optionals.length)
1300
+ node.optionals = optionals;
1301
+ return node;
1302
+ }
1303
+ /** Build the tree node for a single non-leaf child, dispatching on its kind. Null when its control is missing. */
1304
+ buildChildNode(schema, control, label) {
1305
+ if (schema.kind === 'nodeGroup') {
1306
+ return control instanceof FormGroup ? this.buildTree(schema, control, label) : null;
785
1307
  }
786
- return { id: String(this.nextId++), label, children, leaves, leafLists, group };
1308
+ if (schema.kind === 'nodeGroupList') {
1309
+ const array = control;
1310
+ const itemLabel = schema.type.label ?? schema.type.name;
1311
+ const items = array instanceof FormArray
1312
+ ? array.controls
1313
+ .filter((c) => c instanceof FormGroup)
1314
+ .map((item, i) => {
1315
+ // Just "#n": the item sits under its list node, so repeating the
1316
+ // item name (e.g. "Interface #1") only echoes the parent.
1317
+ const node = this.buildTree(schema.type, item, `#${i + 1}`);
1318
+ node.removable = { array, index: i };
1319
+ return node;
1320
+ })
1321
+ : [];
1322
+ return {
1323
+ id: String(this.nextId++),
1324
+ label,
1325
+ children: items,
1326
+ leaves: [],
1327
+ leafLists: [],
1328
+ group: null,
1329
+ list: array instanceof FormArray
1330
+ ? { array, itemSchema: schema.type, itemLabel, minItems: schema.minItems ?? 0 }
1331
+ : undefined,
1332
+ };
1333
+ }
1334
+ if (schema.kind === 'choice') {
1335
+ if (!(control instanceof FormGroup))
1336
+ return null;
1337
+ const active = control.get(CASE_KEY)?.value;
1338
+ const node = active && schema.cases[active]
1339
+ ? this.buildTree(this.caseAsGroup(schema, active), control, label)
1340
+ : { id: String(this.nextId++), label, children: [], leaves: [], leafLists: [], group: control };
1341
+ node.choice = { schema, group: control };
1342
+ return node;
1343
+ }
1344
+ if (schema.kind === 'map') {
1345
+ if (!(control instanceof FormGroup))
1346
+ return null;
1347
+ const complex = schema.value.kind === 'nodeGroup' ||
1348
+ schema.value.kind === 'choice' ||
1349
+ schema.value.kind === 'map' ||
1350
+ schema.value.kind === 'nodeGroupList';
1351
+ const entries = complex
1352
+ ? Object.keys(control.controls)
1353
+ .map((key) => {
1354
+ const entryNode = this.buildChildNode(schema.value, control.get(key), key);
1355
+ if (entryNode)
1356
+ entryNode.mapEntry = { mapGroup: control, mapSchema: schema, key };
1357
+ return entryNode;
1358
+ })
1359
+ .filter((n) => n !== null)
1360
+ : [];
1361
+ return {
1362
+ id: String(this.nextId++),
1363
+ label,
1364
+ children: entries,
1365
+ leaves: [],
1366
+ leafLists: [],
1367
+ group: null,
1368
+ map: { schema, group: control, complex },
1369
+ };
1370
+ }
1371
+ return null;
1372
+ }
1373
+ /** A node's display label: its schema `label`, else its record key. */
1374
+ labelOf(node, key) {
1375
+ return ('label' in node ? node.label : undefined) ?? key;
787
1376
  }
788
1377
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: ConfigEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
789
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: ConfigEditorComponent, isStandalone: true, selector: "nff-config-editor", inputs: { schema: "schema", formGroup: "formGroup", editable: "editable" }, ngImport: i0, template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (node.children.length) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n @if (node.presence) {\n <mat-checkbox\n class=\"presence-check\"\n [checked]=\"!!node.present\"\n [disabled]=\"!editable\"\n (change)=\"setPresence(node, $event.checked)\"\n (click)=\"$event.stopPropagation()\"\n ></mat-checkbox>\n }\n <span class=\"tree-label\" [class.absent]=\"node.presence && !node.present\">{{ node.label }}</span>\n\n @if (editable && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n }\n @if (selected && selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable\"\n />\n }\n @if (!selected.leaves.length && !selected.leafLists.length && !selected.children.length) {\n <p class=\"empty\">This node has no fields.</p>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n } @else if (selected && selected.presence && !selected.present) {\n <p class=\"empty\">This optional group is off. Tick its box in the tree to add it.</p>\n } @else if (selected && selected.list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ selected.list.itemLabel }} items yet.</p>\n }\n @if (editable) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ selected.list.itemLabel }}\n </button>\n </div>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: [":host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 260px;overflow:auto;border:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));border-radius:8px;padding:4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.presence-check{flex:0 0 auto;margin-right:2px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-label.absent{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: LeafRendererComponent, selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable"], outputs: ["remove"] }, { kind: "component", type: LeafListRendererComponent, selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }] });
1378
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: ConfigEditorComponent, isStandalone: true, selector: "nff-config-editor", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: true, transformFunction: null }, formGroup: { classPropertyName: "formGroup", publicName: "formGroup", isSignal: true, isRequired: true, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [class.error]=\"hasError(node)\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (hasExpandableContent(node)) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n <span class=\"tree-label\" [matTooltip]=\"node.label\">{{ node.label }}</span>\n @if (node.choice && activeCaseLabel(node)) {\n <span class=\"tree-sublabel\" [matTooltip]=\"activeCaseLabel(node)\">{{ activeCaseLabel(node) }}</span>\n }\n\n @if (editable() && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.map?.complex && !mapAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n matTooltip=\"Add entry\"\n (click)=\"addTreeMapEntry(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.presenceRemovable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeOptional(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.mapEntry && !mapAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove entry\"\n (click)=\"removeTreeMapEntry(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n @if (editable() && node.optionals?.length) {\n <button\n type=\"button\"\n class=\"tree-row optional-row\"\n [style.padding-left.px]=\"8 + (depth + 1) * 16\"\n [matMenuTriggerFor]=\"treeOptionalsMenu\"\n (click)=\"$event.stopPropagation()\"\n >\n <span class=\"twisty-spacer\"></span>\n <mat-icon class=\"optional-icon\">add</mat-icon>\n <span class=\"optional-label\">Optional field</span>\n </button>\n <mat-menu #treeOptionalsMenu=\"matMenu\">\n @for (opt of node.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(node, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n\n @if (selected.choice; as choice) {\n @if (editable()) {\n <mat-form-field class=\"case-select\" appearance=\"fill\">\n <mat-label>Selected option</mat-label>\n <mat-select [value]=\"activeCase(selected)\" (selectionChange)=\"switchTreeCase(selected, $event.value)\">\n @for (caseName of objectKeys(choice.schema.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(choice.schema, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field class=\"case-select\" appearance=\"outline\">\n <mat-label>Selected option</mat-label>\n <input matInput readonly [value]=\"activeCaseLabel(selected) ?? ''\" />\n </mat-form-field>\n }\n }\n\n @if (selected.mapEntry; as entry) {\n <mat-form-field class=\"key-field\" [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>{{ entry.mapSchema.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #entryKey\n [readonly]=\"!editable()\"\n [value]=\"entry.key\"\n (change)=\"renameTreeMapEntry(selected, entryKey.value); entryKey.value = entry.key\"\n />\n </mat-form-field>\n }\n\n @if (selected.map && !selected.map.complex) {\n <nff-node-map-renderer\n [nodeMap]=\"selected.map.schema\"\n [formGroup]=\"selected.map.group\"\n [editable]=\"editable()\"\n />\n }\n\n @if (selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable()\"\n [removable]=\"!!leaf.optional\"\n [autofocus]=\"leaf.key === focusLeafKey\"\n (remove)=\"removeOptionalLeaf(selected, leaf)\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable()\"\n />\n }\n @if (editable() && selected.optionals?.length) {\n <div class=\"detail-optional\">\n <button matButton [matMenuTriggerFor]=\"detailOptionalsMenu\">\n <mat-icon>add</mat-icon> Optional field\n </button>\n <mat-menu #detailOptionalsMenu=\"matMenu\">\n @for (opt of selected.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(selected, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n </div>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" [matTooltip]=\"child.label\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n }\n\n @if (selected.list; as list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" [matTooltip]=\"item.label\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable() && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ list.itemLabel }} items yet.</p>\n }\n @if (editable()) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ list.itemLabel }}\n </button>\n </div>\n }\n }\n\n @if (selected.map && selected.map.complex) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" [matTooltip]=\"item.label\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable() && !mapAtMin(selected)) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove entry\"\n (click)=\"removeTreeMapEntry(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No entries yet.</p>\n }\n @if (editable() && !mapAtMax(selected)) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addTreeMapEntry(selected)\">\n <mat-icon>add</mat-icon> Add entry\n </button>\n </div>\n }\n }\n\n @if (\n !selected.choice &&\n !selected.mapEntry &&\n !selected.map &&\n !selected.list &&\n !selected.leaves.length &&\n !selected.leafLists.length &&\n !selected.children.length &&\n !(editable() && selected.optionals?.length)\n ) {\n <p class=\"empty\">This node has no fields.</p>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 360px;min-width:360px;overflow:auto;border-right:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));padding:4px 12px 4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.tree-row.error .tree-label{color:var(--mat-sys-error, #b3261e)}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-sublabel{flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;font-size:.85em;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55))}.tree-sublabel:before{content:\"\\b7 \"}.optional-row{width:100%;border:0;background:none;font:inherit;text-align:left}.optional-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-primary, #6750a4)}.optional-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.case-select,.key-field{display:block;max-width:320px;margin-bottom:8px}.detail-optional{display:flex;justify-content:flex-start}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i2$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i4$1.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i4$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i4$2.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i4$2.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i4$2.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i3$1.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: LeafRendererComponent, selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable", "autofocus"], outputs: ["remove"] }, { kind: "component", type: LeafListRendererComponent, selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }, { kind: "component", type: NodeMapRendererComponent, selector: "nff-node-map-renderer", inputs: ["nodeMap", "formGroup", "editable"] }] });
790
1379
  }
791
1380
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: ConfigEditorComponent, decorators: [{
792
1381
  type: Component,
@@ -795,20 +1384,16 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
795
1384
  NgTemplateOutlet,
796
1385
  MatIconModule,
797
1386
  MatButtonModule,
798
- MatCheckboxModule,
1387
+ MatMenuModule,
1388
+ MatFormFieldModule,
1389
+ MatInputModule,
1390
+ MatSelectModule,
799
1391
  MatTooltip,
800
1392
  LeafRendererComponent,
801
1393
  LeafListRendererComponent,
802
- ], template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (node.children.length) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n @if (node.presence) {\n <mat-checkbox\n class=\"presence-check\"\n [checked]=\"!!node.present\"\n [disabled]=\"!editable\"\n (change)=\"setPresence(node, $event.checked)\"\n (click)=\"$event.stopPropagation()\"\n ></mat-checkbox>\n }\n <span class=\"tree-label\" [class.absent]=\"node.presence && !node.present\">{{ node.label }}</span>\n\n @if (editable && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n }\n @if (selected && selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable\"\n />\n }\n @if (!selected.leaves.length && !selected.leafLists.length && !selected.children.length) {\n <p class=\"empty\">This node has no fields.</p>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n } @else if (selected && selected.presence && !selected.present) {\n <p class=\"empty\">This optional group is off. Tick its box in the tree to add it.</p>\n } @else if (selected && selected.list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ selected.list.itemLabel }} items yet.</p>\n }\n @if (editable) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ selected.list.itemLabel }}\n </button>\n </div>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: [":host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 260px;overflow:auto;border:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));border-radius:8px;padding:4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.presence-check{flex:0 0 auto;margin-right:2px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-label.absent{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"] }]
803
- }], propDecorators: { schema: [{
804
- type: Input,
805
- args: [{ required: true }]
806
- }], formGroup: [{
807
- type: Input,
808
- args: [{ required: true }]
809
- }], editable: [{
810
- type: Input
811
- }] } });
1394
+ NodeMapRendererComponent,
1395
+ ], template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [class.error]=\"hasError(node)\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (hasExpandableContent(node)) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n <span class=\"tree-label\" [matTooltip]=\"node.label\">{{ node.label }}</span>\n @if (node.choice && activeCaseLabel(node)) {\n <span class=\"tree-sublabel\" [matTooltip]=\"activeCaseLabel(node)\">{{ activeCaseLabel(node) }}</span>\n }\n\n @if (editable() && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.map?.complex && !mapAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n matTooltip=\"Add entry\"\n (click)=\"addTreeMapEntry(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.presenceRemovable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeOptional(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.mapEntry && !mapAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove entry\"\n (click)=\"removeTreeMapEntry(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n @if (editable() && node.optionals?.length) {\n <button\n type=\"button\"\n class=\"tree-row optional-row\"\n [style.padding-left.px]=\"8 + (depth + 1) * 16\"\n [matMenuTriggerFor]=\"treeOptionalsMenu\"\n (click)=\"$event.stopPropagation()\"\n >\n <span class=\"twisty-spacer\"></span>\n <mat-icon class=\"optional-icon\">add</mat-icon>\n <span class=\"optional-label\">Optional field</span>\n </button>\n <mat-menu #treeOptionalsMenu=\"matMenu\">\n @for (opt of node.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(node, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n\n @if (selected.choice; as choice) {\n @if (editable()) {\n <mat-form-field class=\"case-select\" appearance=\"fill\">\n <mat-label>Selected option</mat-label>\n <mat-select [value]=\"activeCase(selected)\" (selectionChange)=\"switchTreeCase(selected, $event.value)\">\n @for (caseName of objectKeys(choice.schema.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(choice.schema, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field class=\"case-select\" appearance=\"outline\">\n <mat-label>Selected option</mat-label>\n <input matInput readonly [value]=\"activeCaseLabel(selected) ?? ''\" />\n </mat-form-field>\n }\n }\n\n @if (selected.mapEntry; as entry) {\n <mat-form-field class=\"key-field\" [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>{{ entry.mapSchema.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #entryKey\n [readonly]=\"!editable()\"\n [value]=\"entry.key\"\n (change)=\"renameTreeMapEntry(selected, entryKey.value); entryKey.value = entry.key\"\n />\n </mat-form-field>\n }\n\n @if (selected.map && !selected.map.complex) {\n <nff-node-map-renderer\n [nodeMap]=\"selected.map.schema\"\n [formGroup]=\"selected.map.group\"\n [editable]=\"editable()\"\n />\n }\n\n @if (selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable()\"\n [removable]=\"!!leaf.optional\"\n [autofocus]=\"leaf.key === focusLeafKey\"\n (remove)=\"removeOptionalLeaf(selected, leaf)\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable()\"\n />\n }\n @if (editable() && selected.optionals?.length) {\n <div class=\"detail-optional\">\n <button matButton [matMenuTriggerFor]=\"detailOptionalsMenu\">\n <mat-icon>add</mat-icon> Optional field\n </button>\n <mat-menu #detailOptionalsMenu=\"matMenu\">\n @for (opt of selected.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(selected, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n </div>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" [matTooltip]=\"child.label\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n }\n\n @if (selected.list; as list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" [matTooltip]=\"item.label\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable() && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ list.itemLabel }} items yet.</p>\n }\n @if (editable()) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ list.itemLabel }}\n </button>\n </div>\n }\n }\n\n @if (selected.map && selected.map.complex) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" [matTooltip]=\"item.label\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable() && !mapAtMin(selected)) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove entry\"\n (click)=\"removeTreeMapEntry(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No entries yet.</p>\n }\n @if (editable() && !mapAtMax(selected)) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addTreeMapEntry(selected)\">\n <mat-icon>add</mat-icon> Add entry\n </button>\n </div>\n }\n }\n\n @if (\n !selected.choice &&\n !selected.mapEntry &&\n !selected.map &&\n !selected.list &&\n !selected.leaves.length &&\n !selected.leafLists.length &&\n !selected.children.length &&\n !(editable() && selected.optionals?.length)\n ) {\n <p class=\"empty\">This node has no fields.</p>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 360px;min-width:360px;overflow:auto;border-right:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));padding:4px 12px 4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.tree-row.error .tree-label{color:var(--mat-sys-error, #b3261e)}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-sublabel{flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;font-size:.85em;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55))}.tree-sublabel:before{content:\"\\b7 \"}.optional-row{width:100%;border:0;background:none;font:inherit;text-align:left}.optional-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-primary, #6750a4)}.optional-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.case-select,.key-field{display:block;max-width:320px;margin-bottom:8px}.detail-optional{display:flex;justify-content:flex-start}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"] }]
1396
+ }], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: true }] }], formGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "formGroup", required: true }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }] } });
812
1397
 
813
1398
  /*
814
1399
  * Public API Surface of ng-form-foundry
@@ -818,5 +1403,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
818
1403
  * Generated bundle index. Do not edit.
819
1404
  */
820
1405
 
821
- export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, buildControl, buildFormFromSchema, defineSchema };
1406
+ export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, switchChoiceCase };
822
1407
  //# sourceMappingURL=ng-form-foundry.mjs.map