ng-form-foundry 0.3.0 → 0.3.2

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, AfterViewInit, EventEmitter, ElementRef, 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;
@@ -153,7 +153,11 @@ type NodeChoice = {
153
153
  presence?: boolean;
154
154
  appearance?: Appearance;
155
155
  };
156
- /** 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
+ */
157
161
  declare const CASE_KEY = "__case";
158
162
  /**
159
163
  * An open, arbitrary-keyed record: unlike {@link NodeGroup} (a fixed, declared
@@ -189,28 +193,15 @@ type FormGroupType<T extends NodeGroup> = {
189
193
  };
190
194
  type DFormGroup<T extends NodeGroup> = FormGroup<FormGroupType<T>>;
191
195
 
192
- /**
193
- * Build the `AbstractControl` for a single schema node.
194
- *
195
- * Dispatches on `node.kind`: a `leaf` becomes a `FormControl`, a `leafList` a
196
- * `FormArray` of controls, a `nodeGroup` a nested `FormGroup`, and a
197
- * `nodeGroupList` a `FormArray` of groups. `initial` is the runtime value for
198
- * this node — a scalar for a leaf, an array for a list, an object for a group —
199
- * and seeds the control's value (falling back to the node's `default`).
200
- *
201
- * Most callers use {@link buildFormFromSchema}; this is exposed for building a
202
- * control from a single non-root node.
203
- */
204
- /**
205
- * Build the FormGroup for a choice: a `__case` control holding the active case
206
- * name plus that case's field controls. Only the active case's fields are built,
207
- * matching the inline YANG encoding; switching the case swaps them.
208
- */
209
196
  /**
210
197
  * Normalize a {@link ChoiceCase} to a field record. A field record is returned
211
198
  * as-is; a single node (a leaf-bodied case, e.g. a scalar `anyOf` branch) becomes
212
199
  * a one-field record keyed by the node's `name`. The discriminant is a top-level
213
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.
214
205
  */
215
206
  declare function caseFields(body: ChoiceCase): Record<string, NodeType>;
216
207
  /**
@@ -223,7 +214,10 @@ declare function resolveChoiceCase(choice: NodeChoice, initial?: Record<string,
223
214
  /**
224
215
  * Switch a choice's FormGroup to `caseName`: sets `__case`, removes every other
225
216
  * control, and builds `caseName`'s fields (normalized via {@link caseFields})
226
- * with their defaults. An unknown case name leaves only `__case`.
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.
227
221
  */
228
222
  declare function switchChoiceCase(group: FormGroup, choice: NodeChoice, caseName: string): void;
229
223
  /**
@@ -236,15 +230,72 @@ declare function switchChoiceCase(group: FormGroup, choice: NodeChoice, caseName
236
230
  declare function addMapEntry(group: FormGroup, map: NodeMap, key?: string): string | null;
237
231
  /**
238
232
  * Rename entry `oldKey` to `newKey.trim()`, preserving the control instance
239
- * (remove + re-add, so the value survives). Returns whether the rename was
240
- * committed: an empty, unchanged, duplicate, or `keyPattern`-violating key is a
241
- * no-op, leaving the entry under its current name.
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.
242
240
  */
243
241
  declare function renameMapEntry(group: FormGroup, map: NodeMap, oldKey: string, newKey: string): boolean;
244
242
  /** Remove entry `key` unless the map is at `minEntries`. Returns whether it was removed. */
245
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
+ */
246
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
+ */
247
277
  declare function buildFormFromSchema<S extends NodeGroup>(schema: S, initial?: Record<string, unknown> | null): DFormGroup<S>;
278
+ /**
279
+ * The wire value at `node`: `value` rebuilt with every choice discriminator
280
+ * removed.
281
+ *
282
+ * A choice's form value is `{ __case, ...fields }` ({@link CASE_KEY}); its wire
283
+ * encoding is the active case's fields inline, with no discriminator — the case
284
+ * is recovered from the field shape when the data is seeded back in
285
+ * ({@link resolveChoiceCase}). The walk is schema-driven: only positions the
286
+ * schema declares as choices are stripped, so a group child or map entry that
287
+ * happens to be named `__case` passes through untouched. Values at positions
288
+ * the schema does not describe pass through unchanged.
289
+ */
290
+ declare function toWireValue(node: NodeType, value: unknown): unknown;
291
+ /**
292
+ * Serialize a form built by {@link buildFormFromSchema} to its wire value:
293
+ * `form.getRawValue()` with every choice's {@link CASE_KEY} discriminator
294
+ * stripped (see {@link toWireValue}). The result is the inline encoding that
295
+ * `buildFormFromSchema` accepts back as `initial`, so serialize → rebuild
296
+ * round-trips the value.
297
+ */
298
+ declare function serializeForm(schema: NodeGroup, form: FormGroup): Record<string, unknown>;
248
299
  /**
249
300
  * Capture a schema literal with its exact type while checking it against
250
301
  * `NodeGroup`.
@@ -266,56 +317,56 @@ declare function asFormGroup(control: any): FormGroup;
266
317
 
267
318
  declare class DynamicRecursiveFormComponent implements OnInit {
268
319
  /** The form-description schema to render (a root or nested `NodeGroup`). */
269
- readonly schema: i0.InputSignal<NodeGroup>;
320
+ readonly schema: _angular_core.InputSignal<NodeGroup>;
270
321
  /** Optional value object to seed the form; keyed by the schema's `children` keys. */
271
- readonly initialValue: i0.InputSignal<Record<string, unknown> | null>;
322
+ readonly initialValue: _angular_core.InputSignal<Record<string, unknown> | null>;
272
323
  /** The reactive group this form binds to. Defaults to an empty group. */
273
- readonly formGroup: i0.InputSignal<FormGroup<any>>;
324
+ readonly formGroup: _angular_core.InputSignal<FormGroup<any>>;
274
325
  /** Index of this form within a parent list, used by `addButtonCallback`. */
275
- readonly index: i0.InputSignal<number | null>;
326
+ readonly index: _angular_core.InputSignal<number | null>;
276
327
  /** Whether this form may be removed from a parent list (shows a remove control). */
277
- readonly removable: i0.InputSignal<boolean>;
328
+ readonly removable: _angular_core.InputSignal<boolean>;
278
329
  /** Emitted when the user removes this form from a parent list. */
279
- readonly remove: i0.OutputEmitterRef<void>;
330
+ readonly remove: _angular_core.OutputEmitterRef<void>;
280
331
  /** Card/section title; falls back to the schema label or name. */
281
- readonly title: i0.InputSignal<string | undefined>;
332
+ readonly title: _angular_core.InputSignal<string | undefined>;
282
333
  /** Whether fields accept input. Two-way: also toggled by the built-in edit control. */
283
- readonly editable: i0.ModelSignal<boolean>;
334
+ readonly editable: _angular_core.ModelSignal<boolean>;
284
335
  /** Invoked with {@link index} to append a new sibling form to a parent list. */
285
- readonly addButtonCallback: i0.InputSignal<((index: number) => void) | null>;
336
+ readonly addButtonCallback: _angular_core.InputSignal<((index: number) => void) | null>;
337
+ /**
338
+ * Key of a presence leaf whose field should grab focus when it renders — lets
339
+ * a host that added the control itself (e.g. the tree editor's optionals
340
+ * menu) hand focus to the new field, like the form's own add button does.
341
+ */
342
+ readonly focusLeaf: _angular_core.InputSignal<string | null>;
286
343
  /** True when the schema is a root group (rendered flat, without a wrapping card). */
287
- readonly root: i0.Signal<boolean>;
344
+ readonly root: _angular_core.Signal<boolean>;
288
345
  ngOnInit(): void;
289
- get nodeGroupChildrenList(): Array<{
346
+ protected get nodeGroupChildrenList(): Array<{
290
347
  key: string;
291
348
  value: NodeType;
292
349
  }>;
293
- emitRemoveEvent(): void;
294
- /**
295
- * Add or remove a presence group's control on this form. Removing it drops the
296
- * group from `form.value`; adding it rebuilds the sub-group from its schema.
297
- */
298
- togglePresence(key: string, schema: NodeGroup, present: boolean): void;
350
+ protected emitRemoveEvent(): void;
299
351
  /** The key of the presence leaf the user just enabled; its field grabs focus when rendered. */
300
352
  protected presenceFocusKey: string | null;
301
353
  /**
302
- * Add or remove a presence leaf's control on this form. Removing it drops the
303
- * leaf from `form.value`; adding it rebuilds the control from its schema and
304
- * focuses the rendered field.
354
+ * Add or remove a presence node's control on this form any presence kind:
355
+ * group, leaf, map, or choice. Removing it drops the key from `form.value`;
356
+ * adding it builds the control fresh from its schema (nested presence
357
+ * children start absent).
305
358
  */
306
- toggleLeafPresence(key: string, schema: Leaf, present: boolean): void;
359
+ toggleNodePresence(key: string, schema: NodeType, present: boolean): void;
307
360
  /**
308
- * Add or remove a presence map's or choice's control on this form. Removing it
309
- * drops the node from `form.value`; adding it rebuilds the control (an empty
310
- * map, or a choice group holding `__case`) from its schema.
361
+ * {@link toggleNodePresence} for a presence leaf, additionally focusing the
362
+ * rendered field when the toggle just created it.
311
363
  */
312
- toggleNodePresence(key: string, schema: NodeType, present: boolean): void;
313
- protected readonly CASE_KEY = "__case";
314
- objectKeys(obj: Record<string, unknown>): string[];
364
+ toggleLeafPresence(key: string, schema: Leaf, present: boolean): void;
365
+ protected objectKeys(obj: Record<string, unknown>): string[];
315
366
  /** The active case name of a choice control, or null if none is selected. */
316
- activeCase(key: string): string | null;
367
+ protected activeCase(key: string): string | null;
317
368
  /** A synthetic flattened NodeGroup used to render the active case's fields against the choice's FormGroup. */
318
- caseAsGroup(choice: NodeChoice, caseName: string): NodeGroup;
369
+ protected caseAsGroup(choice: NodeChoice, caseName: string): NodeGroup;
319
370
  /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
320
371
  caseLabel(choice: NodeChoice, caseName: string): string;
321
372
  /**
@@ -323,14 +374,14 @@ declare class DynamicRecursiveFormComponent implements OnInit {
323
374
  * for a presence group's body, whose container is the presence panel itself, so
324
375
  * the group's own section panel would be a redundant second box.
325
376
  */
326
- flatGroup(group: NodeGroup): NodeGroup;
377
+ protected flatGroup(group: NodeGroup): NodeGroup;
327
378
  /** Swap a choice's field controls when the selected case changes. Delegates to {@link switchChoiceCase}. */
328
379
  switchCase(key: string, choice: NodeChoice, caseName: string): void;
329
380
  protected readonly asFormGroup: typeof asFormGroup;
330
381
  protected readonly asFormArray: typeof asFormArray;
331
382
  protected readonly asFormControl: typeof asFormControl;
332
- static ɵfac: i0.ɵɵFactoryDeclaration<DynamicRecursiveFormComponent, never>;
333
- static ɵcmp: i0.ɵɵ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; }; }, { "remove": "remove"; "editable": "editableChange"; }, never, never, true, never>;
383
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<DynamicRecursiveFormComponent, never>;
384
+ 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>;
334
385
  }
335
386
 
336
387
  /** Metadata on a list-container node that lets the tree add items to its FormArray. */
@@ -339,51 +390,62 @@ interface ListRef {
339
390
  itemSchema: NodeGroup;
340
391
  itemLabel: string;
341
392
  minItems: number;
393
+ maxItems?: number;
342
394
  }
343
395
  /** An absent optional (presence) child, offered by its parent node's "+ Optional field" menu. */
344
396
  interface OptionalEntry {
345
397
  key: string;
346
398
  schema: NodeType;
347
399
  label: string;
348
- /** Index in the parent schema's children iteration; keeps the menu in schema order. */
349
- order: number;
350
400
  }
351
- /** A navigable node in the config tree. Groups and list items are tree nodes; leaves are their detail. */
401
+ /**
402
+ * One flat section of the detail pane. The selected node's subtree renders as a
403
+ * pre-order list of these — no nesting chrome: each section after the first is
404
+ * separated by a breadcrumb heading (`trail`), and holds only the node's *own*
405
+ * fields (`schema` is a leaf-only slice; complex children are sections of their
406
+ * own, deeper in the list).
407
+ */
408
+ interface DetailSection {
409
+ node: TreeNode;
410
+ /** Selected-node-to-here trail, rendered as the section's breadcrumb heading (omitted on the first section). */
411
+ trail: TreeNode[];
412
+ /** Leaf-only slice of the node's own schema, or null when it has no own fields. */
413
+ schema: NodeGroup | null;
414
+ /** The group `schema` binds to (the node's group; for a choice, the choice group). */
415
+ group: FormGroup | null;
416
+ }
417
+ /**
418
+ * A navigable node in the config tree. Its `id` is the node's stable path from
419
+ * the root (`system/ntp`, `ifaces/0`, `servers/web1`), so expansion and
420
+ * selection survive tree rebuilds. Segments are `%`/`/`-escaped, since map
421
+ * entry keys are arbitrary runtime data; list-item identity is positional.
422
+ */
352
423
  interface TreeNode {
353
424
  id: string;
354
425
  label: string;
355
426
  children: TreeNode[];
356
- /** Leaves editable when this node is selected. A presence leaf carries its `optional` entry so it can be removed back to the menu. */
357
- leaves: {
358
- key: string;
359
- node: Leaf;
360
- optional?: OptionalEntry;
361
- }[];
362
- leafLists: {
363
- key: string;
364
- node: LeafList;
365
- }[];
366
- /** The FormGroup holding this node's leaves, or null for a list-container or map node. */
427
+ /** The node's own FormGroup, or null for a list-container or map node. */
367
428
  group: FormGroup | null;
429
+ /** The node's own schema, set on group-backed nodes (groups, list items, group-valued map entries). */
430
+ schema?: NodeGroup;
368
431
  /** Present on a nodeGroupList node: lets a `+` add an item. */
369
432
  list?: ListRef;
370
- /** Present on a list-item node: the FormArray and current index it can be removed from. */
433
+ /** Present on a list-item node: its current index in the parent list (removal goes through the parent list array). */
371
434
  removable?: {
372
- array: FormArray;
373
435
  index: number;
374
436
  };
375
- /** Absent optional children of this node, offered by its "+ Optional field" menu. */
437
+ /** Absent optional children of this node, offered by its "+ Optional field" menu (schema order). */
376
438
  optionals?: OptionalEntry[];
377
- /** Present on a present optional child node: drives the row's remove control; removal returns `entry` to the parent's menu. */
439
+ /** Present on a present optional child node: drives the row's remove control. */
378
440
  presenceRemovable?: {
379
- entry: OptionalEntry;
441
+ key: string;
380
442
  };
381
443
  /** Present on a choice node: the choice schema and its FormGroup (holding `__case` + the active case's fields). */
382
444
  choice?: {
383
445
  schema: NodeChoice;
384
446
  group: FormGroup;
385
447
  };
386
- /** Present on a map node. `complex` maps expand entries as child nodes; leaf-valued maps edit inline in the detail pane. */
448
+ /** Present on a map node. `complex` maps expand entries as child nodes. */
387
449
  map?: {
388
450
  schema: NodeMap;
389
451
  group: FormGroup;
@@ -398,115 +460,179 @@ interface TreeNode {
398
460
  }
399
461
  /**
400
462
  * A tree/detail editor for a schema-built form: the structure (groups, lists,
401
- * maps, choices) is a tree on the left, and selecting a node shows that node's
402
- * fields for editing on the right. A `+` on a list or map node adds an entry; a
403
- * delete button on each item removes it. Absent optional (presence) children are
404
- * offered by a "+ Optional field" menu row at the end of their parent's
405
- * children; present ones carry a delete button that returns them to the menu.
463
+ * maps, choices) is a tree on the left, and selecting a node renders that
464
+ * node's **entire subtree** on the right as a **flat list of sections** the
465
+ * node's own fields first, then every descendant's fields, each separated by a
466
+ * breadcrumb heading (`Service / Deploy scope / …`) instead of nested panels.
467
+ * Leaf fields render through {@link DynamicRecursiveFormComponent} with a
468
+ * leaf-only schema slice; choice selectors, map rows, and add controls render
469
+ * inline in their section. The tree adds row conveniences of its own: `+` on
470
+ * list and map rows, a delete control on removable rows, and a
471
+ * "+ Optional field" menu for absent presence children.
472
+ *
473
+ * The tree is **derived state**: any structural change to the form — made
474
+ * through the tree rows or through the detail sections — triggers a rebuild (a
475
+ * cheap shape signature over `valueChanges` detects it). Node ids are stable
476
+ * paths, so expansion and selection survive rebuilds. Swapping the `schema` or
477
+ * `formGroup` input rebinds the editor to the new pair, resetting expansion
478
+ * and selection.
406
479
  *
407
480
  * The component draws no outer container — only a divider between the tree and
408
- * detail panes — so the embedding client owns the surrounding chrome. The tree
409
- * is built once from the `schema`/`formGroup` provided at initialization.
410
- * An alternative to the all-in-one {@link DynamicRecursiveFormComponent} for
411
- * large configs.
481
+ * detail panes — so the embedding client owns the surrounding chrome.
412
482
  */
413
- declare class ConfigEditorComponent implements OnInit {
483
+ declare class ConfigEditorComponent implements OnDestroy {
414
484
  /** The form-description schema whose structure the tree renders. */
415
- readonly schema: i0.InputSignal<NodeGroup>;
485
+ readonly schema: _angular_core.InputSignal<NodeGroup>;
416
486
  /** The schema-built reactive group the editor binds to and mutates. */
417
- readonly formGroup: i0.InputSignal<FormGroup<any>>;
487
+ readonly formGroup: _angular_core.InputSignal<FormGroup<any>>;
418
488
  /** Whether fields accept input and structural controls (add/remove/menus) show. */
419
- readonly editable: i0.InputSignal<boolean>;
489
+ readonly editable: _angular_core.InputSignal<boolean>;
420
490
  root: TreeNode;
421
491
  selected: TreeNode | null;
492
+ /** The selected subtree as a flat, breadcrumb-separated section list. */
493
+ sections: DetailSection[];
494
+ /** Root-to-selection trail for the detail-pane breadcrumb, computed once per selection. */
495
+ breadcrumb: TreeNode[];
422
496
  readonly expanded: Set<string>;
423
- private nextId;
424
- ngOnInit(): void;
425
- select(node: TreeNode): void;
426
- /** Select a child (list item or sub-group) from the detail pane, keeping its parent expanded in the tree. */
427
- open(parent: TreeNode, child: TreeNode): void;
497
+ private shape;
498
+ private changes?;
499
+ private readonly host;
500
+ /** Stable per-instance ids for the shape signature, so replacing a control (setControl) reads as a structural change. */
501
+ private readonly controlIds;
502
+ private controlIdSeq;
503
+ constructor();
504
+ ngOnDestroy(): void;
505
+ /** Bind the editor to a schema/form pair: fresh tree, root selection, and shape-sync subscription. */
506
+ private attach;
507
+ select(node: TreeNode, reveal?: boolean): void;
428
508
  /**
429
509
  * Root-to-`target` path for the detail-pane breadcrumb (inclusive of both ends),
430
- * or an empty array if `target` is not in the current tree. Searched fresh each
431
- * call so it stays correct after add/remove/presence mutations rebuild subtrees.
510
+ * or an empty array if `target` is not in the current tree.
432
511
  */
433
512
  pathTo(target: TreeNode): TreeNode[];
434
- toggle(node: TreeNode): void;
513
+ protected toggle(node: TreeNode): void;
514
+ /** Keyboard access for tree rows: Enter/Space selects, ArrowRight expands, ArrowLeft collapses. */
515
+ protected onRowKeydown(event: KeyboardEvent, node: TreeNode): void;
435
516
  /** Whether the row shows an expand twisty: it has child rows to reveal (children or an optionals menu row). */
436
- hasExpandableContent(node: TreeNode): boolean;
517
+ protected hasExpandableContent(node: TreeNode): boolean;
437
518
  /**
438
519
  * Whether the node's form subtree holds a validation error. Group/choice, list,
439
520
  * and map nodes each check their backing control; `invalid` aggregates over
440
521
  * descendants, so an error anywhere below lights every ancestor row.
441
522
  */
442
- hasError(node: TreeNode): boolean;
443
- /** Append a new item to a list node's FormArray and to the tree, then select it. */
523
+ protected hasError(node: TreeNode): boolean;
524
+ /** Append a new item to a list node's FormArray (up to `maxItems`), then select it. */
444
525
  addItem(listNode: TreeNode): void;
445
- /** Remove a list item from its FormArray and the tree (down to `minItems`). */
526
+ /**
527
+ * Remove a list item from its FormArray (down to `minItems`). List-item
528
+ * identity is positional, so expansion state under the list is cleared —
529
+ * otherwise it would silently migrate to the items that shift into the
530
+ * removed indexes.
531
+ */
446
532
  removeItem(listNode: TreeNode, item: TreeNode): void;
447
- /** The key of the optional leaf the user just added; its detail-pane field grabs focus when rendered. */
533
+ /** The just-added optional leaf (section path + key) whose field grabs focus when rendered. */
534
+ protected focusSectionId: string | null;
448
535
  protected focusLeafKey: string | null;
449
- /** Add an absent optional child from the menu: build its control, place it in the tree, and select it. */
536
+ /** Add an absent optional child from the menu: build its control and select the node it lands on. */
450
537
  addOptional(node: TreeNode, entry: OptionalEntry): void;
451
538
  /** Remove a present optional child node, returning its entry to the parent's menu. */
452
539
  removeOptional(parent: TreeNode, node: TreeNode): void;
453
- /** Remove a present optional leaf from the detail pane, returning its entry to the menu. */
454
- removeOptionalLeaf(node: TreeNode, leaf: {
455
- key: string;
456
- node: Leaf;
457
- optional?: OptionalEntry;
458
- }): void;
459
540
  /** The active case name of a choice node, or null when none is selected. */
460
541
  activeCase(node: TreeNode): string | null;
461
542
  /** The display label of a choice node's active case, or null when no case is selected. */
462
543
  activeCaseLabel(node: TreeNode): string | null;
463
544
  /** The display label for a case: the schema's `caseLabels` entry, else the case name. */
464
- caseLabel(choice: NodeChoice, caseName: string): string;
465
- /** Switch a choice node's active case: swap the group's controls and rebuild the subtree in place. */
545
+ protected caseLabel(choice: NodeChoice, caseName: string): string;
546
+ /** Switch a choice node's active case; the structural sync rebuilds the tree and sections. */
466
547
  switchTreeCase(node: TreeNode, caseName: string): void;
548
+ protected objectKeys(obj: Record<string, unknown>): string[];
467
549
  /** Append a new entry to a complex map node under a generated unique key, then select it. */
468
550
  addTreeMapEntry(mapNode: TreeNode): void;
469
- /** Remove a complex map entry (down to `minEntries`) from the form group and the tree. */
551
+ /** Remove a complex map entry (down to `minEntries`). */
470
552
  removeTreeMapEntry(mapNode: TreeNode, entryNode: TreeNode): void;
471
- /** Commit a rename-on-blur of a map entry's key; on success the node is relabeled. */
553
+ /**
554
+ * Commit a rename-on-blur of a map entry's key; on success the entry is
555
+ * selected under its new path and its fresh key field regains focus (the
556
+ * rename re-renders the section under a new id, destroying the input that
557
+ * held focus).
558
+ */
472
559
  renameTreeMapEntry(entryNode: TreeNode, rawKey: string): void;
560
+ /** Move keyboard focus to the selected tree row once the action's re-render settles. */
561
+ private focusSelectedRow;
562
+ /** A muted hint for a section whose node currently renders no content of its own. */
563
+ protected emptySectionHint(s: DetailSection): string | null;
564
+ /** Whether a list node is at `maxItems` (the add control is hidden). */
565
+ protected listAtMax(node: TreeNode | undefined): boolean;
566
+ /** Whether a list node is at `minItems` (item remove controls are hidden). */
567
+ protected listAtMin(node: TreeNode | undefined): boolean;
473
568
  /** Whether a map node is at `maxEntries` (the add control is hidden). */
474
- mapAtMax(node: TreeNode | undefined): boolean;
569
+ protected mapAtMax(node: TreeNode | undefined): boolean;
475
570
  /** Whether a map node is at `minEntries` (entry remove controls are hidden). */
476
- mapAtMin(node: TreeNode | undefined): boolean;
477
- protected readonly asFormControl: typeof asFormControl;
478
- protected readonly asFormArray: typeof asFormArray;
479
- protected readonly CASE_KEY = "__case";
480
- objectKeys(obj: Record<string, unknown>): string[];
481
- /** Re-index and re-label a list node's item children (just "#n") after add/remove. */
482
- private renumber;
483
- /** Select `fallback` when the current selection is no longer reachable in the tree. */
484
- private reselectIfOrphaned;
485
- /** Re-insert a removed optional's entry into the node's menu, keeping schema order. */
486
- private reinsertOptional;
487
- /** Synthetic group over a case's normalized fields, so a case body builds like any group. */
488
- private caseAsGroup;
571
+ protected mapAtMin(node: TreeNode | undefined): boolean;
572
+ /** Rebuild the tree from the current form structure if its shape changed. */
573
+ private syncShape;
574
+ /**
575
+ * A cheap structural signature of a control tree: group keys (plus the active
576
+ * `__case`), array lengths, leaf placeholders. Value edits don't change it;
577
+ * added/removed/renamed controls and case switches do. Reading `__case` from
578
+ * any group is safe because the name is reserved (the builder rejects it as a
579
+ * case field name or map entry key), so only choice groups carry it.
580
+ */
581
+ private shapeOf;
582
+ /** The instance id a container contributes to the shape signature. */
583
+ private uidOf;
584
+ /** Rebuild the whole tree from schema + form and refresh the shape signature. */
585
+ private rebuild;
586
+ /** Re-point `selected` at the rebuilt tree: same path, else the closest surviving ancestor. */
587
+ private reconcileSelection;
588
+ /** The node at `path` in the current tree, or null. Walks by id-prefix segments. */
589
+ private byPath;
590
+ /** Select the node at `path`, falling back through its ancestors to the root. */
591
+ private selectByPath;
592
+ /**
593
+ * Flatten a subtree into detail sections, pre-order: the node's own fields
594
+ * first, then each child as its own breadcrumb-headed section. No nesting
595
+ * chrome — the headings are the boundaries between children.
596
+ */
597
+ private buildSections;
598
+ /** A section's own renderable fields: a leaf-only schema slice and the group it binds to. */
599
+ private sectionContent;
600
+ /**
601
+ * The node's own fields as a flattened schema: leaf and leafList children
602
+ * only. Complex children are rendered as sections of their own, so the
603
+ * embedded form never draws nested section chrome. Null when there are none.
604
+ */
605
+ private leafOnly;
489
606
  private buildTree;
490
607
  /** Build the tree node for a single non-leaf child, dispatching on its kind. Null when its control is missing. */
491
608
  private buildChildNode;
609
+ /** Synthetic group over a case's normalized fields, so a case body builds like any group. */
610
+ private caseAsGroup;
492
611
  /** A node's display label: its schema `label`, else its record key. */
493
612
  private labelOf;
494
- static ɵfac: i0.ɵɵFactoryDeclaration<ConfigEditorComponent, never>;
495
- static ɵcmp: i0.ɵɵ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>;
613
+ /** Join a parent path and a segment; the root's path is the empty string. */
614
+ private join;
615
+ /**
616
+ * Escape a path segment: `/` is the id separator, and map entry keys are
617
+ * arbitrary runtime data that may contain it. Labels stay unescaped — only
618
+ * node identities encode.
619
+ */
620
+ private escapeSeg;
621
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<ConfigEditorComponent, never>;
622
+ 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>;
496
623
  }
497
624
 
498
- declare class LeafRendererComponent implements OnInit, AfterViewInit {
625
+ declare class LeafRendererComponent implements AfterViewInit {
499
626
  private readonly elementRef;
500
627
  leaf_: Leaf | AnonLeaf;
501
628
  control: FormControl;
502
- initialValue?: any;
503
629
  removable: boolean;
504
630
  editable: boolean;
505
631
  /** Focus the field once rendered — for fields the user just added (e.g. an enabled presence leaf). */
506
632
  autofocus: boolean;
507
- remove: EventEmitter<number>;
633
+ /** Emitted when the user removes this field (e.g. an optional presence leaf). */
634
+ readonly remove: _angular_core.OutputEmitterRef<void>;
508
635
  constructor(elementRef: ElementRef<HTMLElement>);
509
- ngOnInit(): void;
510
636
  ngAfterViewInit(): void;
511
637
  /** Whether this field accepts input: the form is editable and the leaf is not `readOnly`. */
512
638
  get fieldEditable(): boolean;
@@ -516,8 +642,8 @@ declare class LeafRendererComponent implements OnInit, AfterViewInit {
516
642
  * state (invalid and touched), so it can be bound unconditionally.
517
643
  */
518
644
  get errorText(): string;
519
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafRendererComponent, never>;
520
- 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; }; "autofocus": { "alias": "autofocus"; "required": false; }; }, { "remove": "remove"; }, never, never, true, never>;
645
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafRendererComponent, never>;
646
+ 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>;
521
647
  }
522
648
 
523
649
  declare class LeafListRendererComponent implements OnInit {
@@ -538,8 +664,8 @@ declare class LeafListRendererComponent implements OnInit {
538
664
  ngOnInit(): void;
539
665
  removeItem($index: number): void;
540
666
  addItem(): void;
541
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafListRendererComponent, never>;
542
- 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>;
667
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafListRendererComponent, never>;
668
+ 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>;
543
669
  }
544
670
 
545
671
  declare class LeafEnumRendererComponent {
@@ -549,8 +675,8 @@ declare class LeafEnumRendererComponent {
549
675
  removable: boolean;
550
676
  remove: any;
551
677
  editable: boolean;
552
- static ɵfac: i0.ɵɵFactoryDeclaration<LeafEnumRendererComponent, never>;
553
- 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>;
678
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<LeafEnumRendererComponent, never>;
679
+ 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>;
554
680
  }
555
681
 
556
682
  declare class NodeGroupListRendererComponent implements OnInit, AfterViewInit {
@@ -570,8 +696,8 @@ declare class NodeGroupListRendererComponent implements OnInit, AfterViewInit {
570
696
  setLastEditable(): void;
571
697
  asFormGroup(group: any): FormGroup;
572
698
  getTitle($index: number): string;
573
- static ɵfac: i0.ɵɵFactoryDeclaration<NodeGroupListRendererComponent, never>;
574
- 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>;
699
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<NodeGroupListRendererComponent, never>;
700
+ 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>;
575
701
  }
576
702
 
577
703
  /**
@@ -581,13 +707,13 @@ declare class NodeGroupListRendererComponent implements OnInit, AfterViewInit {
581
707
  * names are the entry keys, so the key is edited as a *rename* (remove + re-add
582
708
  * the same control) committed on blur — see {@link renameEntry}.
583
709
  */
584
- declare class NodeMapRendererComponent implements OnInit {
710
+ declare class NodeMapRendererComponent implements OnChanges {
585
711
  nodeMap: NodeMap;
586
712
  formGroup: FormGroup<any>;
587
713
  editable: boolean;
588
714
  /** Ordered view of entry keys, kept stable across renames (`addControl` appends). */
589
715
  entryKeys: string[];
590
- ngOnInit(): void;
716
+ ngOnChanges(changes: SimpleChanges): void;
591
717
  /** The value schema as a leaf (used when `value.kind === 'leaf'`). */
592
718
  get valueLeaf(): Leaf;
593
719
  /** The value schema as a group (used when `value.kind === 'nodeGroup'`). */
@@ -606,9 +732,9 @@ declare class NodeMapRendererComponent implements OnInit {
606
732
  renameEntry(oldKey: string, rawKey: string): void;
607
733
  protected readonly asFormControl: typeof asFormControl;
608
734
  protected readonly asFormGroup: typeof asFormGroup;
609
- static ɵfac: i0.ɵɵFactoryDeclaration<NodeMapRendererComponent, never>;
610
- static ɵcmp: i0.ɵɵ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>;
735
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<NodeMapRendererComponent, never>;
736
+ 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>;
611
737
  }
612
738
 
613
- export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, switchChoiceCase };
739
+ export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, serializeForm, switchChoiceCase, toWireValue };
614
740
  export type { AnonLeaf, Appearance, ChoiceCase, DFormControl, DFormGroup, FormGroupType, Leaf, LeafBase, LeafBoolean, LeafEnum, LeafList, LeafNumber, LeafRuntimeType, LeafString, NodeChoice, NodeGroup, NodeGroupList, NodeMap, NodeType };