ng-form-foundry 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { FormControl, FormArray, FormGroup } from '@angular/forms';
2
- import * as i0 from '@angular/core';
3
- import { OnInit, EventEmitter, AfterViewInit, QueryList, ChangeDetectorRef } from '@angular/core';
2
+ import * as _angular_core from '@angular/core';
3
+ import { OnInit, OnDestroy, AfterViewInit, ElementRef, EventEmitter, QueryList, ChangeDetectorRef, OnChanges, SimpleChanges } from '@angular/core';
4
4
  import { MatDialog } from '@angular/material/dialog';
5
5
 
6
6
  type LeafRuntimeType<T> = T extends 'string' ? string : T extends 'number' ? number : T extends 'boolean' ? boolean : T extends 'enum' ? string | number : never;
@@ -10,6 +10,26 @@ type LeafBase = {
10
10
  required?: true | undefined;
11
11
  label?: string;
12
12
  description?: string;
13
+ /**
14
+ * The value may be `null` (JSON Schema `type: [T, 'null']`). Builds a nullable
15
+ * control (drops `nonNullable`), so `null` is a first-class value the
16
+ * constraint validators accept and that survives the round-trip. Distinct from
17
+ * {@link presence}: `nullable` is an explicit `null`, `presence` is an absent key.
18
+ */
19
+ nullable?: boolean;
20
+ /**
21
+ * Optional scalar whose *presence itself* is data (mirrors {@link NodeGroup.presence}).
22
+ * Rendered with an on/off toggle; the control is removed from the parent group
23
+ * when absent (so it drops from `form.value`) and re-added when toggled on. The
24
+ * builder omits it unless an initial value is supplied.
25
+ */
26
+ presence?: boolean;
27
+ /**
28
+ * Render the field read-only even when the surrounding form is editable.
29
+ * Combine with `default` to express a JSON Schema `const` (a fixed,
30
+ * display-only value); single-element `enum` is the alternative for a constant.
31
+ */
32
+ readOnly?: boolean;
13
33
  };
14
34
  type AnonLeaf = {
15
35
  [K in Leaf['type']]: {
@@ -19,10 +39,30 @@ type AnonLeaf = {
19
39
  type LeafString = LeafBase & {
20
40
  type: 'string';
21
41
  default?: LeafRuntimeType<'string'>;
42
+ /**
43
+ * Reject values that don't match this regular expression. Follows JSON Schema
44
+ * `pattern` semantics — an *unanchored* `RegExp.test`, so it matches anywhere
45
+ * in the value unless the pattern itself anchors with `^`/`$`.
46
+ */
47
+ pattern?: string;
48
+ /** Minimum string length (JSON Schema `minLength`). */
49
+ minLength?: number;
50
+ /** Maximum string length (JSON Schema `maxLength`). */
51
+ maxLength?: number;
52
+ /** Semantic string format (JSON Schema `format`); adds a matching validator. */
53
+ format?: 'email' | 'uri' | 'url';
22
54
  };
23
55
  type LeafNumber = LeafBase & {
24
56
  type: 'number';
25
57
  default?: LeafRuntimeType<'number'>;
58
+ /** Require a whole-number value (JSON Schema `type: 'integer'`). */
59
+ integer?: boolean;
60
+ /** Inclusive lower bound (JSON Schema `minimum`). */
61
+ min?: number;
62
+ /** Inclusive upper bound (JSON Schema `maximum`). */
63
+ max?: number;
64
+ /** Require the value to be an integer multiple of this number (JSON Schema `multipleOf`). */
65
+ multipleOf?: number;
26
66
  };
27
67
  type LeafBoolean = LeafBase & {
28
68
  type: 'boolean';
@@ -37,6 +77,8 @@ type LeafEnum = LeafBase & {
37
77
  type Appearance = {
38
78
  flatten?: boolean;
39
79
  noBorder?: boolean;
80
+ /** Start this node's section panel collapsed. Ignored when `flatten` is set. */
81
+ collapsed?: boolean;
40
82
  };
41
83
  type Leaf = LeafString | LeafNumber | LeafBoolean | LeafEnum;
42
84
  type LeafList<TKind extends Leaf['type'] = Leaf['type']> = {
@@ -75,30 +117,163 @@ type NodeGroup = {
75
117
  children: Record<string, NodeType>;
76
118
  appearance?: Appearance;
77
119
  };
120
+ /**
121
+ * One case of a {@link NodeChoice}: either a record of named fields (an object
122
+ * branch), or a single node (a *leaf-bodied* case — e.g. an `anyOf` branch that
123
+ * is a bare scalar). A single node is normalized to a one-field record keyed by
124
+ * its `name` when the form is built.
125
+ */
126
+ type ChoiceCase = Record<string, NodeType> | NodeType;
78
127
  /**
79
128
  * A discriminated selection: the user picks one `case`, and only that case's
80
129
  * fields are present. In the form it is a FormGroup holding a `__case` control
81
130
  * (the active case name) plus that case's field controls; switching the case
82
131
  * swaps the field controls.
132
+ *
133
+ * Cases may be **anonymous / auto-named** (any string key) — for JSON Schema
134
+ * `anyOf`/`oneOf` branches with no name. When a built form is seeded from inline
135
+ * data that carries no `__case`, the builder **infers** the active case from the
136
+ * data shape (the case whose fields best match), so a choice round-trips from
137
+ * real instance data. See the schema reference for the required-set / `const`
138
+ * discriminator recipe.
83
139
  */
84
140
  type NodeChoice = {
85
141
  kind: 'choice';
86
142
  name: string;
87
143
  label?: string;
88
- cases: Record<string, Record<string, NodeType>>;
144
+ cases: Record<string, ChoiceCase>;
145
+ /**
146
+ * Display labels for cases, keyed by case name — for anonymous/auto-named
147
+ * branches whose keys are not human-friendly. Falls back to the case name.
148
+ */
149
+ caseLabels?: Record<string, string>;
89
150
  default?: string;
90
151
  mandatory?: boolean;
152
+ /** Optional choice: rendered with an on/off toggle, omitted from the value when absent. */
153
+ presence?: boolean;
154
+ appearance?: Appearance;
91
155
  };
92
- /** The control name that records which case of a {@link NodeChoice} is active. */
156
+ /**
157
+ * The control name that records which case of a {@link NodeChoice} is active.
158
+ * The name is reserved: it cannot be used as a case field name (the builder
159
+ * throws) or as a map entry key (the entry helpers reject it).
160
+ */
93
161
  declare const CASE_KEY = "__case";
94
- type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | NodeChoice;
95
- type DFormControl<T extends NodeType> = T extends Leaf ? FormControl<LeafRuntimeType<T['type']>> : T extends LeafList ? FormArray<FormControl<LeafRuntimeType<T['type']>>> : T extends NodeGroup ? DFormGroup<T> : T extends NodeGroupList ? FormArray<DFormGroup<T['type']>> : T extends NodeChoice ? FormGroup<any> : never;
162
+ /**
163
+ * An open, arbitrary-keyed record: unlike {@link NodeGroup} (a fixed, declared
164
+ * key set), a map's keys are runtime data and every value conforms to one shared
165
+ * `value` schema. Maps JSON Schema `additionalProperties: <schema>` /
166
+ * `patternProperties`. In the form it is a `FormGroup` whose control *names* are
167
+ * the entry keys, so `getRawValue()` is the map object directly; the renderer
168
+ * lets the user add, remove, and rename entries.
169
+ */
170
+ type NodeMap = {
171
+ kind: 'map';
172
+ name: string;
173
+ label?: string;
174
+ description?: string;
175
+ /** The schema every entry's value conforms to. */
176
+ value: NodeType;
177
+ /** Label for the key column in the editor. Defaults to "Key". */
178
+ keyLabel?: string;
179
+ /** `patternProperties`: entry keys must match this regular expression. */
180
+ keyPattern?: string;
181
+ /** Minimum number of entries (JSON Schema `minProperties`). */
182
+ minEntries?: number;
183
+ /** Maximum number of entries (JSON Schema `maxProperties`). */
184
+ maxEntries?: number;
185
+ /** Optional map: rendered with an on/off toggle, omitted from the value when absent. */
186
+ presence?: boolean;
187
+ appearance?: Appearance;
188
+ };
189
+ type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | NodeChoice | NodeMap;
190
+ type DFormControl<T extends NodeType> = T extends Leaf ? FormControl<LeafRuntimeType<T['type']>> : T extends LeafList ? FormArray<FormControl<LeafRuntimeType<T['type']>>> : T extends NodeGroup ? DFormGroup<T> : T extends NodeGroupList ? FormArray<DFormGroup<T['type']>> : T extends NodeChoice ? FormGroup<any> : T extends NodeMap ? FormGroup<any> : never;
96
191
  type FormGroupType<T extends NodeGroup> = {
97
192
  [TChild in keyof T['children']]: DFormControl<T['children'][TChild]>;
98
193
  };
99
194
  type DFormGroup<T extends NodeGroup> = FormGroup<FormGroupType<T>>;
100
195
 
196
+ /**
197
+ * Normalize a {@link ChoiceCase} to a field record. A field record is returned
198
+ * as-is; a single node (a leaf-bodied case, e.g. a scalar `anyOf` branch) becomes
199
+ * a one-field record keyed by the node's `name`. The discriminant is a top-level
200
+ * `kind` string, which a field record never has (its keys are field names).
201
+ *
202
+ * Throws when a case field is keyed `__case`: that name is reserved for the
203
+ * choice discriminator ({@link CASE_KEY}) and a field under it would silently
204
+ * clobber the active-case control.
205
+ */
206
+ declare function caseFields(body: ChoiceCase): Record<string, NodeType>;
207
+ /**
208
+ * The active case of a choice: an explicit `__case` in the initial value, else
209
+ * the case whose fields best match the initial data (inline wire data carries no
210
+ * `__case`), else the schema `default`. This lets a choice seed from real
211
+ * instance data whose branch is discriminated by which fields are present.
212
+ */
213
+ declare function resolveChoiceCase(choice: NodeChoice, initial?: Record<string, unknown> | null): string | undefined;
214
+ /**
215
+ * Switch a choice's FormGroup to `caseName`: sets `__case`, removes every other
216
+ * control, and builds `caseName`'s fields (normalized via {@link caseFields})
217
+ * with their defaults. Presence fields of the new case start absent — the
218
+ * switch carries no data that could make them present. An unknown case name
219
+ * leaves only `__case`. The swap is atomic: one value change fires, and every
220
+ * observable snapshot has fields matching its discriminator.
221
+ */
222
+ declare function switchChoiceCase(group: FormGroup, choice: NodeChoice, caseName: string): void;
223
+ /**
224
+ * Append a map entry built from `map.value` and return its committed key, or
225
+ * `null` when nothing was added. With no `key`, the first free `keyN`
226
+ * placeholder is generated (not checked against `keyPattern` — placeholders are
227
+ * meant to be renamed). An explicit `key` is rejected when it duplicates an
228
+ * existing entry or violates `keyPattern`. Rejects when `maxEntries` is reached.
229
+ */
230
+ declare function addMapEntry(group: FormGroup, map: NodeMap, key?: string): string | null;
231
+ /**
232
+ * Rename entry `oldKey` to `newKey.trim()`, preserving the control instance
233
+ * (so the value survives) and the entry's position in the group's key order —
234
+ * the order `getRawValue()` serializes and the tree editor renders. Returns
235
+ * whether the rename was committed: an empty, reserved (`__case`), unchanged,
236
+ * duplicate, or `keyPattern`-violating key is a no-op, leaving the entry under
237
+ * its current name. Entry keys are looked up verbatim — never via
238
+ * `AbstractControl.get`, which would split keys like `10.0.0.1` into
239
+ * dot-delimited paths. Emits a single value change.
240
+ */
241
+ declare function renameMapEntry(group: FormGroup, map: NodeMap, oldKey: string, newKey: string): boolean;
242
+ /** Remove entry `key` unless the map is at `minEntries`. Returns whether it was removed. */
243
+ declare function removeMapEntry(group: FormGroup, map: NodeMap, key: string): boolean;
244
+ /**
245
+ * Build the `AbstractControl` for a single schema node.
246
+ *
247
+ * Dispatches on `node.kind`: a `leaf` becomes a `FormControl`, a `leafList` a
248
+ * `FormArray` of controls, a `nodeGroup` a nested `FormGroup`, and a
249
+ * `nodeGroupList` a `FormArray` of groups. `initial` is the runtime value for
250
+ * this node — a scalar for a leaf, an array for a list, an object for a group —
251
+ * and seeds the control's value (falling back to the node's `default`).
252
+ *
253
+ * Most callers use {@link buildFormFromSchema}; this is exposed for building a
254
+ * control from a single non-root node.
255
+ */
101
256
  declare function buildControl<T extends NodeType>(node: T, initial?: unknown | null): DFormControl<T> | FormControl<LeafRuntimeType<any>> | FormArray<any>;
257
+ /**
258
+ * Build a typed `FormGroup` from a root `NodeGroup` schema.
259
+ *
260
+ * The returned group's control structure, keys, and value types are inferred
261
+ * from the schema literal — a `leaf` of `type: 'number'` yields a
262
+ * `FormControl<number>`, a `nodeGroup` a nested `FormGroup`, and so on. `initial`
263
+ * is an optional value object keyed by the schema's `children` keys; each child
264
+ * control is seeded from its matching slice (falling back to the node `default`).
265
+ *
266
+ * Presence nodes whose key is absent from `initial` get no control, at any depth
267
+ * — plain children, list items, map values, and choice case fields alike — so
268
+ * they are absent from the form value until enabled. A key present with an
269
+ * explicit `null` keeps its control: `null` is a value, absence is the missing
270
+ * key.
271
+ *
272
+ * Inference only holds when `schema`'s literal type is preserved. Author schemas
273
+ * with {@link defineSchema} or a `satisfies NodeGroup` annotation — never
274
+ * `const schema: NodeGroup = ...`, which widens `children` and erases the field
275
+ * names and value types.
276
+ */
102
277
  declare function buildFormFromSchema<S extends NodeGroup>(schema: S, initial?: Record<string, unknown> | null): DFormGroup<S>;
103
278
  /**
104
279
  * Capture a schema literal with its exact type while checking it against
@@ -120,40 +295,72 @@ declare function asFormArray(control: any): FormArray;
120
295
  declare function asFormGroup(control: any): FormGroup;
121
296
 
122
297
  declare class DynamicRecursiveFormComponent implements OnInit {
123
- schema: NodeGroup;
124
- initialValue: any;
125
- formGroup: FormGroup<any>;
126
- index: number | null;
127
- removable: boolean;
128
- remove: EventEmitter<any>;
129
- title: string;
130
- editable: boolean;
131
- addButtonCallback: ((index: number) => void) | null;
132
- root: boolean;
298
+ /** The form-description schema to render (a root or nested `NodeGroup`). */
299
+ readonly schema: _angular_core.InputSignal<NodeGroup>;
300
+ /** Optional value object to seed the form; keyed by the schema's `children` keys. */
301
+ readonly initialValue: _angular_core.InputSignal<Record<string, unknown> | null>;
302
+ /** The reactive group this form binds to. Defaults to an empty group. */
303
+ readonly formGroup: _angular_core.InputSignal<FormGroup<any>>;
304
+ /** Index of this form within a parent list, used by `addButtonCallback`. */
305
+ readonly index: _angular_core.InputSignal<number | null>;
306
+ /** Whether this form may be removed from a parent list (shows a remove control). */
307
+ readonly removable: _angular_core.InputSignal<boolean>;
308
+ /** Emitted when the user removes this form from a parent list. */
309
+ readonly remove: _angular_core.OutputEmitterRef<void>;
310
+ /** Card/section title; falls back to the schema label or name. */
311
+ readonly title: _angular_core.InputSignal<string | undefined>;
312
+ /** Whether fields accept input. Two-way: also toggled by the built-in edit control. */
313
+ readonly editable: _angular_core.ModelSignal<boolean>;
314
+ /** Invoked with {@link index} to append a new sibling form to a parent list. */
315
+ readonly addButtonCallback: _angular_core.InputSignal<((index: number) => void) | null>;
316
+ /**
317
+ * Key of a presence leaf whose field should grab focus when it renders — lets
318
+ * a host that added the control itself (e.g. the tree editor's optionals
319
+ * menu) hand focus to the new field, like the form's own add button does.
320
+ */
321
+ readonly focusLeaf: _angular_core.InputSignal<string | null>;
322
+ /** True when the schema is a root group (rendered flat, without a wrapping card). */
323
+ readonly root: _angular_core.Signal<boolean>;
133
324
  ngOnInit(): void;
134
- get nodeGroupChildrenList(): Array<{
325
+ protected get nodeGroupChildrenList(): Array<{
135
326
  key: string;
136
327
  value: NodeType;
137
328
  }>;
138
- emitRemoveEvent(): void;
329
+ protected emitRemoveEvent(): void;
330
+ /** The key of the presence leaf the user just enabled; its field grabs focus when rendered. */
331
+ protected presenceFocusKey: string | null;
332
+ /**
333
+ * Add or remove a presence node's control on this form — any presence kind:
334
+ * group, leaf, map, or choice. Removing it drops the key from `form.value`;
335
+ * adding it builds the control fresh from its schema (nested presence
336
+ * children start absent).
337
+ */
338
+ toggleNodePresence(key: string, schema: NodeType, present: boolean): void;
139
339
  /**
140
- * Add or remove a presence group's control on this form. Removing it drops the
141
- * group from `form.value`; adding it rebuilds the sub-group from its schema.
340
+ * {@link toggleNodePresence} for a presence leaf, additionally focusing the
341
+ * rendered field when the toggle just created it.
142
342
  */
143
- togglePresence(key: string, schema: NodeGroup, present: boolean): void;
144
- protected readonly CASE_KEY = "__case";
145
- objectKeys(obj: Record<string, unknown>): string[];
343
+ toggleLeafPresence(key: string, schema: Leaf, present: boolean): void;
344
+ protected objectKeys(obj: Record<string, unknown>): string[];
146
345
  /** The active case name of a choice control, or null if none is selected. */
147
- activeCase(key: string): string | null;
346
+ protected activeCase(key: string): string | null;
148
347
  /** A synthetic flattened NodeGroup used to render the active case's fields against the choice's FormGroup. */
149
- caseAsGroup(choice: NodeChoice, caseName: string): NodeGroup;
150
- /** Swap a choice's field controls when the selected case changes. */
348
+ protected caseAsGroup(choice: NodeChoice, caseName: string): NodeGroup;
349
+ /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
350
+ caseLabel(choice: NodeChoice, caseName: string): string;
351
+ /**
352
+ * A copy of a group flagged to render its fields inline (no inner panel). Used
353
+ * for a presence group's body, whose container is the presence panel itself, so
354
+ * the group's own section panel would be a redundant second box.
355
+ */
356
+ protected flatGroup(group: NodeGroup): NodeGroup;
357
+ /** Swap a choice's field controls when the selected case changes. Delegates to {@link switchChoiceCase}. */
151
358
  switchCase(key: string, choice: NodeChoice, caseName: string): void;
152
359
  protected readonly asFormGroup: typeof asFormGroup;
153
360
  protected readonly asFormArray: typeof asFormArray;
154
361
  protected readonly asFormControl: typeof asFormControl;
155
- static ɵfac: i0.ɵɵFactoryDeclaration<DynamicRecursiveFormComponent, never>;
156
- static ɵcmp: i0.ɵɵComponentDeclaration<DynamicRecursiveFormComponent, "nff-dynamic-recursive-form", never, { "schema": { "alias": "schema"; "required": true; }; "initialValue": { "alias": "initialValue"; "required": false; }; "formGroup": { "alias": "formGroup"; "required": false; }; "index": { "alias": "index"; "required": false; }; "removable": { "alias": "removable"; "required": false; }; "title": { "alias": "title"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "addButtonCallback": { "alias": "addButtonCallback"; "required": false; }; }, { "remove": "remove"; }, never, never, true, never>;
362
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<DynamicRecursiveFormComponent, never>;
363
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<DynamicRecursiveFormComponent, "nff-dynamic-recursive-form", never, { "schema": { "alias": "schema"; "required": true; "isSignal": true; }; "initialValue": { "alias": "initialValue"; "required": false; "isSignal": true; }; "formGroup": { "alias": "formGroup"; "required": false; "isSignal": true; }; "index": { "alias": "index"; "required": false; "isSignal": true; }; "removable": { "alias": "removable"; "required": false; "isSignal": true; }; "title": { "alias": "title"; "required": false; "isSignal": true; }; "editable": { "alias": "editable"; "required": false; "isSignal": true; }; "addButtonCallback": { "alias": "addButtonCallback"; "required": false; "isSignal": true; }; "focusLeaf": { "alias": "focusLeaf"; "required": false; "isSignal": true; }; }, { "remove": "remove"; "editable": "editableChange"; }, never, never, true, never>;
157
364
  }
158
365
 
159
366
  /** Metadata on a list-container node that lets the tree add items to its FormArray. */
@@ -162,91 +369,260 @@ interface ListRef {
162
369
  itemSchema: NodeGroup;
163
370
  itemLabel: string;
164
371
  minItems: number;
372
+ maxItems?: number;
165
373
  }
166
- /** A navigable node in the config tree. Groups and list items are tree nodes; leaves are their detail. */
374
+ /** An absent optional (presence) child, offered by its parent node's "+ Optional field" menu. */
375
+ interface OptionalEntry {
376
+ key: string;
377
+ schema: NodeType;
378
+ label: string;
379
+ }
380
+ /**
381
+ * One flat section of the detail pane. The selected node's subtree renders as a
382
+ * pre-order list of these — no nesting chrome: each section after the first is
383
+ * separated by a breadcrumb heading (`trail`), and holds only the node's *own*
384
+ * fields (`schema` is a leaf-only slice; complex children are sections of their
385
+ * own, deeper in the list).
386
+ */
387
+ interface DetailSection {
388
+ node: TreeNode;
389
+ /** Selected-node-to-here trail, rendered as the section's breadcrumb heading (omitted on the first section). */
390
+ trail: TreeNode[];
391
+ /** Leaf-only slice of the node's own schema, or null when it has no own fields. */
392
+ schema: NodeGroup | null;
393
+ /** The group `schema` binds to (the node's group; for a choice, the choice group). */
394
+ group: FormGroup | null;
395
+ }
396
+ /**
397
+ * A navigable node in the config tree. Its `id` is the node's stable path from
398
+ * the root (`system/ntp`, `ifaces/0`, `servers/web1`), so expansion and
399
+ * selection survive tree rebuilds. Segments are `%`/`/`-escaped, since map
400
+ * entry keys are arbitrary runtime data; list-item identity is positional.
401
+ */
167
402
  interface TreeNode {
168
403
  id: string;
169
404
  label: string;
170
405
  children: TreeNode[];
171
- /** Leaves editable when this node is selected. Empty for list-container nodes. */
172
- leaves: {
173
- key: string;
174
- node: Leaf;
175
- }[];
176
- leafLists: {
177
- key: string;
178
- node: LeafList;
179
- }[];
180
- /** The FormGroup holding this node's leaves, or null for a list-container node. */
406
+ /** The node's own FormGroup, or null for a list-container or map node. */
181
407
  group: FormGroup | null;
408
+ /** The node's own schema, set on group-backed nodes (groups, list items, group-valued map entries). */
409
+ schema?: NodeGroup;
182
410
  /** Present on a nodeGroupList node: lets a `+` add an item. */
183
411
  list?: ListRef;
184
- /** Present on a list-item node: the FormArray and current index it can be removed from. */
412
+ /** Present on a list-item node: its current index in the parent list (removal goes through the parent list array). */
185
413
  removable?: {
186
- array: FormArray;
187
414
  index: number;
188
415
  };
189
- /** Present on an optional (presence) group node: lets a checkbox add/remove the group. */
190
- presence?: {
191
- parentGroup: FormGroup;
416
+ /** Absent optional children of this node, offered by its "+ Optional field" menu (schema order). */
417
+ optionals?: OptionalEntry[];
418
+ /** Present on a present optional child node: drives the row's remove control. */
419
+ presenceRemovable?: {
420
+ key: string;
421
+ };
422
+ /** Present on a choice node: the choice schema and its FormGroup (holding `__case` + the active case's fields). */
423
+ choice?: {
424
+ schema: NodeChoice;
425
+ group: FormGroup;
426
+ };
427
+ /** Present on a map node. `complex` maps expand entries as child nodes. */
428
+ map?: {
429
+ schema: NodeMap;
430
+ group: FormGroup;
431
+ complex: boolean;
432
+ };
433
+ /** Present on a complex-map entry node: addresses the entry in its map for remove/rename. */
434
+ mapEntry?: {
435
+ mapGroup: FormGroup;
436
+ mapSchema: NodeMap;
192
437
  key: string;
193
- schema: NodeGroup;
194
438
  };
195
- /** For a presence node: whether the group is currently present. */
196
- present?: boolean;
197
439
  }
198
440
  /**
199
- * A tree/detail editor for a schema-built form: the structure (containers, lists,
200
- * groups) is a tree on the left, and selecting a node shows that node's leaf
201
- * fields for editing on the right. A `+` on a list node adds an entry; a delete
202
- * button on each item removes it. An alternative to the all-in-one
203
- * {@link DynamicRecursiveFormComponent} for large configs.
441
+ * A tree/detail editor for a schema-built form: the structure (groups, lists,
442
+ * maps, choices) is a tree on the left, and selecting a node renders that
443
+ * node's **entire subtree** on the right as a **flat list of sections** the
444
+ * node's own fields first, then every descendant's fields, each separated by a
445
+ * breadcrumb heading (`Service / Deploy scope / …`) instead of nested panels.
446
+ * Leaf fields render through {@link DynamicRecursiveFormComponent} with a
447
+ * leaf-only schema slice; choice selectors, map rows, and add controls render
448
+ * inline in their section. The tree adds row conveniences of its own: `+` on
449
+ * list and map rows, a delete control on removable rows, and a
450
+ * "+ Optional field" menu for absent presence children.
451
+ *
452
+ * The tree is **derived state**: any structural change to the form — made
453
+ * through the tree rows or through the detail sections — triggers a rebuild (a
454
+ * cheap shape signature over `valueChanges` detects it). Node ids are stable
455
+ * paths, so expansion and selection survive rebuilds. Swapping the `schema` or
456
+ * `formGroup` input rebinds the editor to the new pair, resetting expansion
457
+ * and selection.
458
+ *
459
+ * The component draws no outer container — only a divider between the tree and
460
+ * detail panes — so the embedding client owns the surrounding chrome.
204
461
  */
205
- declare class ConfigEditorComponent implements OnInit {
206
- schema: NodeGroup;
207
- formGroup: FormGroup;
208
- editable: boolean;
462
+ declare class ConfigEditorComponent implements OnDestroy {
463
+ /** The form-description schema whose structure the tree renders. */
464
+ readonly schema: _angular_core.InputSignal<NodeGroup>;
465
+ /** The schema-built reactive group the editor binds to and mutates. */
466
+ readonly formGroup: _angular_core.InputSignal<FormGroup<any>>;
467
+ /** Whether fields accept input and structural controls (add/remove/menus) show. */
468
+ readonly editable: _angular_core.InputSignal<boolean>;
209
469
  root: TreeNode;
210
470
  selected: TreeNode | null;
471
+ /** The selected subtree as a flat, breadcrumb-separated section list. */
472
+ sections: DetailSection[];
473
+ /** Root-to-selection trail for the detail-pane breadcrumb, computed once per selection. */
474
+ breadcrumb: TreeNode[];
211
475
  readonly expanded: Set<string>;
212
- private nextId;
213
- ngOnInit(): void;
214
- select(node: TreeNode): void;
215
- /** Select a child (list item or sub-group) from the detail pane, keeping its parent expanded in the tree. */
216
- open(parent: TreeNode, child: TreeNode): void;
476
+ private shape;
477
+ private changes?;
478
+ private readonly host;
479
+ /** Stable per-instance ids for the shape signature, so replacing a control (setControl) reads as a structural change. */
480
+ private readonly controlIds;
481
+ private controlIdSeq;
482
+ constructor();
483
+ ngOnDestroy(): void;
484
+ /** Bind the editor to a schema/form pair: fresh tree, root selection, and shape-sync subscription. */
485
+ private attach;
486
+ select(node: TreeNode, reveal?: boolean): void;
217
487
  /**
218
488
  * Root-to-`target` path for the detail-pane breadcrumb (inclusive of both ends),
219
- * or an empty array if `target` is not in the current tree. Searched fresh each
220
- * call so it stays correct after add/remove/presence mutations rebuild subtrees.
489
+ * or an empty array if `target` is not in the current tree.
221
490
  */
222
491
  pathTo(target: TreeNode): TreeNode[];
223
- toggle(node: TreeNode): void;
224
- /** Append a new item to a list node's FormArray and to the tree, then select it. */
492
+ protected toggle(node: TreeNode): void;
493
+ /** Keyboard access for tree rows: Enter/Space selects, ArrowRight expands, ArrowLeft collapses. */
494
+ protected onRowKeydown(event: KeyboardEvent, node: TreeNode): void;
495
+ /** Whether the row shows an expand twisty: it has child rows to reveal (children or an optionals menu row). */
496
+ protected hasExpandableContent(node: TreeNode): boolean;
497
+ /**
498
+ * Whether the node's form subtree holds a validation error. Group/choice, list,
499
+ * and map nodes each check their backing control; `invalid` aggregates over
500
+ * descendants, so an error anywhere below lights every ancestor row.
501
+ */
502
+ protected hasError(node: TreeNode): boolean;
503
+ /** Append a new item to a list node's FormArray (up to `maxItems`), then select it. */
225
504
  addItem(listNode: TreeNode): void;
226
- /** Remove a list item from its FormArray and the tree (down to `minItems`). */
505
+ /**
506
+ * Remove a list item from its FormArray (down to `minItems`). List-item
507
+ * identity is positional, so expansion state under the list is cleared —
508
+ * otherwise it would silently migrate to the items that shift into the
509
+ * removed indexes.
510
+ */
227
511
  removeItem(listNode: TreeNode, item: TreeNode): void;
228
- /** Enable or disable an optional (presence) group by adding/removing its control. */
229
- setPresence(node: TreeNode, present: boolean): void;
230
- protected readonly asFormControl: typeof asFormControl;
231
- protected readonly asFormArray: typeof asFormArray;
232
- private placeholder;
233
- /** Re-index and re-label a list node's item children (just "#n") after add/remove. */
234
- private renumber;
512
+ /** The just-added optional leaf (section path + key) whose field grabs focus when rendered. */
513
+ protected focusSectionId: string | null;
514
+ protected focusLeafKey: string | null;
515
+ /** Add an absent optional child from the menu: build its control and select the node it lands on. */
516
+ addOptional(node: TreeNode, entry: OptionalEntry): void;
517
+ /** Remove a present optional child node, returning its entry to the parent's menu. */
518
+ removeOptional(parent: TreeNode, node: TreeNode): void;
519
+ /** The active case name of a choice node, or null when none is selected. */
520
+ activeCase(node: TreeNode): string | null;
521
+ /** The display label of a choice node's active case, or null when no case is selected. */
522
+ activeCaseLabel(node: TreeNode): string | null;
523
+ /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
524
+ protected caseLabel(choice: NodeChoice, caseName: string): string;
525
+ /** Switch a choice node's active case; the structural sync rebuilds the tree and sections. */
526
+ switchTreeCase(node: TreeNode, caseName: string): void;
527
+ protected objectKeys(obj: Record<string, unknown>): string[];
528
+ /** Append a new entry to a complex map node under a generated unique key, then select it. */
529
+ addTreeMapEntry(mapNode: TreeNode): void;
530
+ /** Remove a complex map entry (down to `minEntries`). */
531
+ removeTreeMapEntry(mapNode: TreeNode, entryNode: TreeNode): void;
532
+ /**
533
+ * Commit a rename-on-blur of a map entry's key; on success the entry is
534
+ * selected under its new path and its fresh key field regains focus (the
535
+ * rename re-renders the section under a new id, destroying the input that
536
+ * held focus).
537
+ */
538
+ renameTreeMapEntry(entryNode: TreeNode, rawKey: string): void;
539
+ /** Move keyboard focus to the selected tree row once the action's re-render settles. */
540
+ private focusSelectedRow;
541
+ /** A muted hint for a section whose node currently renders no content of its own. */
542
+ protected emptySectionHint(s: DetailSection): string | null;
543
+ /** Whether a list node is at `maxItems` (the add control is hidden). */
544
+ protected listAtMax(node: TreeNode | undefined): boolean;
545
+ /** Whether a list node is at `minItems` (item remove controls are hidden). */
546
+ protected listAtMin(node: TreeNode | undefined): boolean;
547
+ /** Whether a map node is at `maxEntries` (the add control is hidden). */
548
+ protected mapAtMax(node: TreeNode | undefined): boolean;
549
+ /** Whether a map node is at `minEntries` (entry remove controls are hidden). */
550
+ protected mapAtMin(node: TreeNode | undefined): boolean;
551
+ /** Rebuild the tree from the current form structure if its shape changed. */
552
+ private syncShape;
553
+ /**
554
+ * A cheap structural signature of a control tree: group keys (plus the active
555
+ * `__case`), array lengths, leaf placeholders. Value edits don't change it;
556
+ * added/removed/renamed controls and case switches do. Reading `__case` from
557
+ * any group is safe because the name is reserved (the builder rejects it as a
558
+ * case field name or map entry key), so only choice groups carry it.
559
+ */
560
+ private shapeOf;
561
+ /** The instance id a container contributes to the shape signature. */
562
+ private uidOf;
563
+ /** Rebuild the whole tree from schema + form and refresh the shape signature. */
564
+ private rebuild;
565
+ /** Re-point `selected` at the rebuilt tree: same path, else the closest surviving ancestor. */
566
+ private reconcileSelection;
567
+ /** The node at `path` in the current tree, or null. Walks by id-prefix segments. */
568
+ private byPath;
569
+ /** Select the node at `path`, falling back through its ancestors to the root. */
570
+ private selectByPath;
571
+ /**
572
+ * Flatten a subtree into detail sections, pre-order: the node's own fields
573
+ * first, then each child as its own breadcrumb-headed section. No nesting
574
+ * chrome — the headings are the boundaries between children.
575
+ */
576
+ private buildSections;
577
+ /** A section's own renderable fields: a leaf-only schema slice and the group it binds to. */
578
+ private sectionContent;
579
+ /**
580
+ * The node's own fields as a flattened schema: leaf and leafList children
581
+ * only. Complex children are rendered as sections of their own, so the
582
+ * embedded form never draws nested section chrome. Null when there are none.
583
+ */
584
+ private leafOnly;
235
585
  private buildTree;
236
- static ɵfac: i0.ɵɵFactoryDeclaration<ConfigEditorComponent, never>;
237
- static ɵcmp: i0.ɵɵComponentDeclaration<ConfigEditorComponent, "nff-config-editor", never, { "schema": { "alias": "schema"; "required": true; }; "formGroup": { "alias": "formGroup"; "required": true; }; "editable": { "alias": "editable"; "required": false; }; }, {}, never, never, true, never>;
586
+ /** Build the tree node for a single non-leaf child, dispatching on its kind. Null when its control is missing. */
587
+ private buildChildNode;
588
+ /** Synthetic group over a case's normalized fields, so a case body builds like any group. */
589
+ private caseAsGroup;
590
+ /** A node's display label: its schema `label`, else its record key. */
591
+ private labelOf;
592
+ /** Join a parent path and a segment; the root's path is the empty string. */
593
+ private join;
594
+ /**
595
+ * Escape a path segment: `/` is the id separator, and map entry keys are
596
+ * arbitrary runtime data that may contain it. Labels stay unescaped — only
597
+ * node identities encode.
598
+ */
599
+ private escapeSeg;
600
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<ConfigEditorComponent, never>;
601
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<ConfigEditorComponent, "nff-config-editor", never, { "schema": { "alias": "schema"; "required": true; "isSignal": true; }; "formGroup": { "alias": "formGroup"; "required": true; "isSignal": true; }; "editable": { "alias": "editable"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
238
602
  }
239
603
 
240
- declare class LeafRendererComponent implements OnInit {
604
+ declare class LeafRendererComponent implements AfterViewInit {
605
+ private readonly elementRef;
241
606
  leaf_: Leaf | AnonLeaf;
242
607
  control: FormControl;
243
- initialValue?: any;
244
608
  removable: boolean;
245
609
  editable: boolean;
246
- remove: EventEmitter<number>;
247
- ngOnInit(): void;
248
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafRendererComponent, never>;
249
- static ɵcmp: i0.ɵɵComponentDeclaration<LeafRendererComponent, "nff-leaf-renderer", never, { "leaf_": { "alias": "leaf_"; "required": false; }; "control": { "alias": "control"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "removable": { "alias": "removable"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; }, { "remove": "remove"; }, never, never, true, never>;
610
+ /** Focus the field once rendered — for fields the user just added (e.g. an enabled presence leaf). */
611
+ autofocus: boolean;
612
+ /** Emitted when the user removes this field (e.g. an optional presence leaf). */
613
+ readonly remove: _angular_core.OutputEmitterRef<void>;
614
+ constructor(elementRef: ElementRef<HTMLElement>);
615
+ ngAfterViewInit(): void;
616
+ /** Whether this field accepts input: the form is editable and the leaf is not `readOnly`. */
617
+ get fieldEditable(): boolean;
618
+ /**
619
+ * A human-readable message for the control's active validation error, or `''`
620
+ * when valid. `mat-form-field` only shows it once the field is in an error
621
+ * state (invalid and touched), so it can be bound unconditionally.
622
+ */
623
+ get errorText(): string;
624
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafRendererComponent, never>;
625
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<LeafRendererComponent, "nff-leaf-renderer", never, { "leaf_": { "alias": "leaf_"; "required": false; }; "control": { "alias": "control"; "required": false; }; "removable": { "alias": "removable"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "autofocus": { "alias": "autofocus"; "required": false; }; }, { "remove": "remove"; }, never, never, true, never>;
250
626
  }
251
627
 
252
628
  declare class LeafListRendererComponent implements OnInit {
@@ -267,8 +643,8 @@ declare class LeafListRendererComponent implements OnInit {
267
643
  ngOnInit(): void;
268
644
  removeItem($index: number): void;
269
645
  addItem(): void;
270
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafListRendererComponent, never>;
271
- static ɵcmp: i0.ɵɵComponentDeclaration<LeafListRendererComponent, "nff-leaf-list-renderer", never, { "leaf_": { "alias": "leaf_"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "formArray": { "alias": "formArray"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "minItems": { "alias": "minItems"; "required": false; }; }, { "message": "message"; }, never, never, true, never>;
646
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafListRendererComponent, never>;
647
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<LeafListRendererComponent, "nff-leaf-list-renderer", never, { "leaf_": { "alias": "leaf_"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "formArray": { "alias": "formArray"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "minItems": { "alias": "minItems"; "required": false; }; }, { "message": "message"; }, never, never, true, never>;
272
648
  }
273
649
 
274
650
  declare class LeafEnumRendererComponent {
@@ -278,8 +654,8 @@ declare class LeafEnumRendererComponent {
278
654
  removable: boolean;
279
655
  remove: any;
280
656
  editable: boolean;
281
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafEnumRendererComponent, never>;
282
- static ɵcmp: i0.ɵɵComponentDeclaration<LeafEnumRendererComponent, "nff-leaf-enum-renderer", never, { "leafEnum": { "alias": "leafEnum"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "control": { "alias": "control"; "required": false; }; "removable": { "alias": "removable"; "required": false; }; "remove": { "alias": "remove"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; }, {}, never, never, true, never>;
657
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafEnumRendererComponent, never>;
658
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<LeafEnumRendererComponent, "nff-leaf-enum-renderer", never, { "leafEnum": { "alias": "leafEnum"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "control": { "alias": "control"; "required": false; }; "removable": { "alias": "removable"; "required": false; }; "remove": { "alias": "remove"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; }, {}, never, never, true, never>;
283
659
  }
284
660
 
285
661
  declare class NodeGroupListRendererComponent implements OnInit, AfterViewInit {
@@ -299,9 +675,45 @@ declare class NodeGroupListRendererComponent implements OnInit, AfterViewInit {
299
675
  setLastEditable(): void;
300
676
  asFormGroup(group: any): FormGroup;
301
677
  getTitle($index: number): string;
302
- static ɵfac: i0.ɵɵFactoryDeclaration<NodeGroupListRendererComponent, never>;
303
- static ɵcmp: i0.ɵɵComponentDeclaration<NodeGroupListRendererComponent, "nff-node-group-list-renderer", never, { "nodeGroupList": { "alias": "nodeGroupList"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "formArray": { "alias": "formArray"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "minItems": { "alias": "minItems"; "required": false; }; "maxItems": { "alias": "maxItems"; "required": false; }; }, { "message": "message"; }, never, never, true, never>;
678
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<NodeGroupListRendererComponent, never>;
679
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<NodeGroupListRendererComponent, "nff-node-group-list-renderer", never, { "nodeGroupList": { "alias": "nodeGroupList"; "required": false; }; "initialValue": { "alias": "initialValue"; "required": false; }; "formArray": { "alias": "formArray"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; "minItems": { "alias": "minItems"; "required": false; }; "maxItems": { "alias": "maxItems"; "required": false; }; }, { "message": "message"; }, never, never, true, never>;
680
+ }
681
+
682
+ /**
683
+ * Renders a {@link NodeMap}: an open, arbitrary-keyed record. Each entry is a row
684
+ * with an editable key and the shared value schema's control; the user can add,
685
+ * remove, and rename entries. The map's control is a `FormGroup` whose control
686
+ * names are the entry keys, so the key is edited as a *rename* (remove + re-add
687
+ * the same control) committed on blur — see {@link renameEntry}.
688
+ */
689
+ declare class NodeMapRendererComponent implements OnChanges {
690
+ nodeMap: NodeMap;
691
+ formGroup: FormGroup<any>;
692
+ editable: boolean;
693
+ /** Ordered view of entry keys, kept stable across renames (`addControl` appends). */
694
+ entryKeys: string[];
695
+ ngOnChanges(changes: SimpleChanges): void;
696
+ /** The value schema as a leaf (used when `value.kind === 'leaf'`). */
697
+ get valueLeaf(): Leaf;
698
+ /** The value schema as a group (used when `value.kind === 'nodeGroup'`). */
699
+ get valueGroup(): NodeGroup;
700
+ get atMax(): boolean;
701
+ get atMin(): boolean;
702
+ /** Append a new entry under a unique placeholder key. Delegates to {@link addMapEntry}. */
703
+ addEntry(): void;
704
+ /** Drop an entry (its control leaves the group, so it drops from the value). Delegates to {@link removeMapEntry}. */
705
+ removeEntry(key: string): void;
706
+ /**
707
+ * Commit an edited key by renaming its control once (the value is preserved).
708
+ * An empty, unchanged, duplicate, or `keyPattern`-violating key is a no-op,
709
+ * leaving the entry under its current name. Delegates to {@link renameMapEntry}.
710
+ */
711
+ renameEntry(oldKey: string, rawKey: string): void;
712
+ protected readonly asFormControl: typeof asFormControl;
713
+ protected readonly asFormGroup: typeof asFormGroup;
714
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<NodeMapRendererComponent, never>;
715
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<NodeMapRendererComponent, "nff-node-map-renderer", never, { "nodeMap": { "alias": "nodeMap"; "required": false; }; "formGroup": { "alias": "formGroup"; "required": false; }; "editable": { "alias": "editable"; "required": false; }; }, {}, never, never, true, never>;
304
716
  }
305
717
 
306
- export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, buildControl, buildFormFromSchema, defineSchema };
307
- export type { AnonLeaf, Appearance, DFormControl, DFormGroup, FormGroupType, Leaf, LeafBase, LeafBoolean, LeafEnum, LeafList, LeafNumber, LeafRuntimeType, LeafString, NodeChoice, NodeGroup, NodeGroupList, NodeType };
718
+ export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, switchChoiceCase };
719
+ export type { AnonLeaf, Appearance, ChoiceCase, DFormControl, DFormGroup, FormGroupType, Leaf, LeafBase, LeafBoolean, LeafEnum, LeafList, LeafNumber, LeafRuntimeType, LeafString, NodeChoice, NodeGroup, NodeGroupList, NodeMap, NodeType };