@rokkit/forms 1.0.0-next.137 → 1.0.0-next.138

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,3 +1,4 @@
1
+ import { SvelteMap, SvelteSet } from 'svelte/reactivity'
1
2
  import { deriveSchemaFromValue } from './schema.js'
2
3
  import { deriveLayoutFromValue } from './layout.js'
3
4
  import { getSchemaWithLayout } from './fields.js'
@@ -19,6 +20,35 @@ function deepClone(value) {
19
20
  return JSON.parse(JSON.stringify(value))
20
21
  }
21
22
 
23
+ /** @private */
24
+ function deepEqualArrays(a, b) {
25
+ if (a.length !== b.length) return false
26
+ return a.every((val, i) => deepEqual(val, b[i]))
27
+ }
28
+
29
+ /** @private */
30
+ function deepEqualObjects(a, b) {
31
+ const keysA = Object.keys(a)
32
+ const keysB = Object.keys(b)
33
+ if (keysA.length !== keysB.length) return false
34
+ return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
35
+ }
36
+
37
+ /** @private — true when either value is nullish */
38
+ function isNullish(v) {
39
+ return v === null || v === undefined
40
+ }
41
+
42
+ /** @private — true when both are same-type objects (not mixed array/object) */
43
+ function areSameObjectType(a, b) {
44
+ return typeof a === 'object' && typeof b === 'object' && Array.isArray(a) === Array.isArray(b)
45
+ }
46
+
47
+ /** @private — dispatch object comparison to array or plain-object helper */
48
+ function deepEqualObjects2(a, b) {
49
+ return Array.isArray(a) ? deepEqualArrays(a, b) : deepEqualObjects(a, b)
50
+ }
51
+
22
52
  /**
23
53
  * Deep equality check for plain values (primitives, plain objects, arrays).
24
54
  * @param {any} a
@@ -27,20 +57,8 @@ function deepClone(value) {
27
57
  */
28
58
  function deepEqual(a, b) {
29
59
  if (a === b) return true
30
- if (a === null || a === undefined || b === null || b === undefined) return a === b
31
- if (typeof a !== typeof b) return false
32
- if (typeof a !== 'object') return false
33
- if (Array.isArray(a) !== Array.isArray(b)) return false
34
-
35
- if (Array.isArray(a)) {
36
- if (a.length !== b.length) return false
37
- return a.every((val, i) => deepEqual(val, b[i]))
38
- }
39
-
40
- const keysA = Object.keys(a)
41
- const keysB = Object.keys(b)
42
- if (keysA.length !== keysB.length) return false
43
- return keysA.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key]))
60
+ if (isNullish(a) || isNullish(b)) return false
61
+ return areSameObjectType(a, b) && deepEqualObjects2(a, b)
44
62
  }
45
63
 
46
64
  /**
@@ -62,6 +80,28 @@ function deepEqual(a, b) {
62
80
  * @property {boolean} [props.dirty] - Whether field value differs from initial
63
81
  */
64
82
 
83
+ /** Maps simple schema types to their input type string */
84
+ const SCHEMA_TYPE_MAP = { boolean: 'checkbox', array: 'array' }
85
+
86
+ /**
87
+ * Update a nested data path and return the updated root object
88
+ * @private
89
+ * @param {Object} data - Current data
90
+ * @param {string[]} keys - Path segments
91
+ * @param {any} value - New value
92
+ * @returns {Object}
93
+ */
94
+ function setNestedValue(data, keys, value) {
95
+ const updated = { ...data }
96
+ let current = updated
97
+ for (let i = 0; i < keys.length - 1; i++) {
98
+ current[keys[i]] = { ...current[keys[i]] }
99
+ current = current[keys[i]]
100
+ }
101
+ current[keys[keys.length - 1]] = value
102
+ return updated
103
+ }
104
+
65
105
  /**
66
106
  * FormBuilder class for dynamically generating forms from data structures
67
107
  */
@@ -160,6 +200,17 @@ export class FormBuilder {
160
200
  this.#validation = value
161
201
  }
162
202
 
203
+ /**
204
+ * Initialise the lookup manager if any lookups are provided
205
+ * @private
206
+ */
207
+ #initLookups(lookups) {
208
+ this.#lookupConfigs = lookups
209
+ if (Object.keys(lookups).length > 0) {
210
+ this.#lookupManager = createLookupManager(lookups)
211
+ }
212
+ }
213
+
163
214
  /**
164
215
  * Create a new FormBuilder instance
165
216
  * @param {Object} [data={}] - Initial data object
@@ -172,10 +223,7 @@ export class FormBuilder {
172
223
  this.#initialData = deepClone(data)
173
224
  this.schema = schema
174
225
  this.layout = layout
175
- this.#lookupConfigs = lookups
176
- if (Object.keys(lookups).length > 0) {
177
- this.#lookupManager = createLookupManager(lookups)
178
- }
226
+ this.#initLookups(lookups)
179
227
  }
180
228
 
181
229
  /**
@@ -251,6 +299,20 @@ export class FormBuilder {
251
299
  }
252
300
  }
253
301
 
302
+ /**
303
+ * Clear dependent field values for a changed path
304
+ * @private
305
+ */
306
+ #clearDependentFields(path) {
307
+ for (const [depPath, lookup] of this.#lookupManager.lookups) {
308
+ if (!lookup.dependsOn.includes(path)) continue
309
+ const depKeys = depPath.split('/')
310
+ if (depKeys.length === 1) {
311
+ this.#data = { ...this.#data, [depKeys[0]]: null }
312
+ }
313
+ }
314
+ }
315
+
254
316
  /**
255
317
  * Update a specific field value
256
318
  * @param {string} path - Field path (e.g., 'count', 'settings/distance')
@@ -258,20 +320,11 @@ export class FormBuilder {
258
320
  * @param {boolean} [triggerLookups=true] - Whether to trigger dependent lookups
259
321
  */
260
322
  updateField(path, value, triggerLookups = true) {
261
- // Simple path handling for now - can be enhanced for nested objects
262
323
  const keys = path.split('/')
263
324
  if (keys.length === 1) {
264
325
  this.#data = { ...this.#data, [keys[0]]: value }
265
326
  } else {
266
- // Handle nested paths if needed
267
- const updatedData = { ...this.#data }
268
- let current = updatedData
269
- for (let i = 0; i < keys.length - 1; i++) {
270
- current[keys[i]] = { ...current[keys[i]] }
271
- current = current[keys[i]]
272
- }
273
- current[keys[keys.length - 1]] = value
274
- this.#data = updatedData
327
+ this.#data = setNestedValue(this.#data, keys, value)
275
328
  }
276
329
 
277
330
  // Clear stale validation errors for fields that are now hidden
@@ -279,15 +332,7 @@ export class FormBuilder {
279
332
 
280
333
  // Trigger dependent lookups if configured
281
334
  if (triggerLookups && this.#lookupManager) {
282
- // Clear dependent field values synchronously before lookup re-fetch
283
- for (const [depPath, lookup] of this.#lookupManager.lookups) {
284
- if (lookup.dependsOn.includes(path)) {
285
- const depKeys = depPath.split('/')
286
- if (depKeys.length === 1) {
287
- this.#data = { ...this.#data, [depKeys[0]]: null }
288
- }
289
- }
290
- }
335
+ this.#clearDependentFields(path)
291
336
  this.#lookupManager.handleFieldChange(path, this.#data)
292
337
  }
293
338
  }
@@ -317,7 +362,7 @@ export class FormBuilder {
317
362
  * @private
318
363
  */
319
364
  #clearHiddenValidation() {
320
- const visiblePaths = new Set(
365
+ const visiblePaths = new SvelteSet(
321
366
  this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
322
367
  )
323
368
  const cleaned = Object.fromEntries(
@@ -328,6 +373,85 @@ export class FormBuilder {
328
373
  }
329
374
  }
330
375
 
376
+ /**
377
+ * Build a display element from the layout entry
378
+ * @private
379
+ */
380
+ #buildDisplayElement(layoutEl) {
381
+ const scope = layoutEl.scope ?? null
382
+ const fieldPath = scope?.replace(/^#\//, '')
383
+ const value = fieldPath ? this.getValue(fieldPath) : null
384
+ const { type: displayType, scope: _s, ...displayProps } = layoutEl
385
+ return { type: displayType, scope, value, override: false, props: displayProps }
386
+ }
387
+
388
+ /**
389
+ * Build a non-scoped separator/spacer element from the layout entry
390
+ * @private
391
+ */
392
+ #buildSeparatorElement(layoutEl) {
393
+ const { type: separatorType, ...separatorProps } = layoutEl
394
+ return {
395
+ type: separatorType ?? 'separator',
396
+ scope: null,
397
+ value: null,
398
+ override: false,
399
+ props: separatorProps
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Process one scoped layout element — returns FormElement or null
405
+ * @private
406
+ */
407
+ #processScopedElement(layoutEl, combinedMap) {
408
+ if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) return null
409
+ const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
410
+ const combinedEl = combinedMap.get(key)
411
+ return combinedEl ? this.#convertToFormElement(combinedEl) : null
412
+ }
413
+
414
+ /**
415
+ * Build the combined element map from schema+layout
416
+ * @private
417
+ */
418
+ #buildCombinedMap(layoutElements) {
419
+ const scopedElements = layoutElements.filter(
420
+ (el) => el.scope && !el.type?.startsWith('display-')
421
+ )
422
+ const scopedLayout = { ...this.#layout, elements: scopedElements }
423
+ const combined = getSchemaWithLayout(this.#schema, scopedLayout)
424
+
425
+ const combinedMap = new SvelteMap()
426
+ for (const el of combined.elements ?? []) {
427
+ if (el.key) combinedMap.set(el.key, el)
428
+ }
429
+ return combinedMap
430
+ }
431
+
432
+ /**
433
+ * Convert one layout element to a form element (or null)
434
+ * @private
435
+ */
436
+ #buildOneElement(layoutEl, combinedMap) {
437
+ if (layoutEl.type?.startsWith('display-')) return this.#buildDisplayElement(layoutEl)
438
+ if (!layoutEl.scope) return this.#buildSeparatorElement(layoutEl)
439
+ return this.#processScopedElement(layoutEl, combinedMap)
440
+ }
441
+
442
+ /**
443
+ * Collect form elements from layout into result array
444
+ * @private
445
+ */
446
+ #collectElements(layoutElements, combinedMap) {
447
+ const result = []
448
+ for (const layoutEl of layoutElements) {
449
+ const formEl = this.#buildOneElement(layoutEl, combinedMap)
450
+ if (formEl) result.push(formEl)
451
+ }
452
+ return result
453
+ }
454
+
331
455
  /**
332
456
  * Build form elements from schema and layout using getSchemaWithLayout
333
457
  * @private
@@ -335,65 +459,10 @@ export class FormBuilder {
335
459
  */
336
460
  #buildElements() {
337
461
  try {
338
- const result = []
339
462
  const layoutElements = this.#layout?.elements ?? []
340
- // Track which layout elements have scopes (for schema merge)
341
- // Exclude display-* elements — they are handled separately
342
- const scopedElements = layoutElements.filter(
343
- (el) => el.scope && !el.type?.startsWith('display-')
344
- )
345
- const scopedLayout = { ...this.#layout, elements: scopedElements }
346
- const combined = getSchemaWithLayout(this.#schema, scopedLayout)
347
-
348
- // Build a map of combined elements by key for lookup
349
- const combinedMap = new Map()
350
- for (const el of combined.elements ?? []) {
351
- if (el.key) combinedMap.set(el.key, el)
352
- }
353
-
354
- // Iterate original layout order to preserve separators and other non-scoped elements
355
- for (const layoutEl of layoutElements) {
356
- if (layoutEl.type?.startsWith('display-')) {
357
- // Display element — resolve data from scope if present
358
- const scope = layoutEl.scope ?? null
359
- const fieldPath = scope?.replace(/^#\//, '')
360
- const value = fieldPath ? this.getValue(fieldPath) : null
361
- const { type: displayType, scope: _s, ...displayProps } = layoutEl
362
- result.push({
363
- type: displayType,
364
- scope,
365
- value,
366
- override: false,
367
- props: displayProps
368
- })
369
- } else if (!layoutEl.scope) {
370
- // Non-scoped element (separator, etc.)
371
- const { type: separatorType, ...separatorProps } = layoutEl
372
- result.push({
373
- type: separatorType ?? 'separator',
374
- scope: null,
375
- value: null,
376
- override: false,
377
- props: separatorProps
378
- })
379
- } else {
380
- // Check showWhen condition before processing scoped field
381
- if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) {
382
- continue
383
- }
384
-
385
- // Extract key from scope
386
- const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
387
- const combinedEl = combinedMap.get(key)
388
- if (combinedEl) {
389
- const formEl = this.#convertToFormElement(combinedEl)
390
- if (formEl) result.push(formEl)
391
- }
392
- }
393
- }
394
- return result
463
+ const combinedMap = this.#buildCombinedMap(layoutElements)
464
+ return this.#collectElements(layoutElements, combinedMap)
395
465
  } catch (error) {
396
- // If getSchemaWithLayout fails, fall back to basic element creation
397
466
  console.warn('Failed to build elements:', error) // eslint-disable-line no-console
398
467
  return this.#buildBasicElements()
399
468
  }
@@ -456,122 +525,141 @@ export class FormBuilder {
456
525
  }
457
526
 
458
527
  /**
459
- * Convert a combined schema/layout element to FormElement format
528
+ * Convert a nested (group) element to FormElement format
460
529
  * @private
461
- * @param {Object} element - Combined element from getSchemaWithLayout
462
- * @param {string} parentPath - Parent path for nested elements
463
- * @returns {FormElement} Form element
464
530
  */
465
- #convertToFormElement(element, parentPath = '') {
466
- const { key, props } = element
531
+ #convertNestedElement(element, fieldPath, scope, value) {
532
+ const nestedElements = element.elements.map((child) =>
533
+ this.#convertToFormElement(child, fieldPath)
534
+ )
535
+ const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
536
+ return {
537
+ scope,
538
+ type: 'group',
539
+ value,
540
+ override: element.override || false,
541
+ props: {
542
+ ...topLevelProps,
543
+ ...groupProps,
544
+ elements: nestedElements,
545
+ message: this.#validation[fieldPath] || null
546
+ }
547
+ }
548
+ }
467
549
 
468
- // Skip elements without a key
469
- if (!key) {
470
- return null
550
+ /**
551
+ * Convert a readonly element to FormElement format
552
+ * @private
553
+ */
554
+ #convertReadonlyElement(element, fieldPath, scope, value) {
555
+ const validationMessage = this.#validation[fieldPath] || null
556
+ return {
557
+ scope,
558
+ type: 'info',
559
+ value,
560
+ override: element.override || false,
561
+ props: { ...element.props, type: 'info', message: validationMessage }
471
562
  }
563
+ }
472
564
 
473
- // Create scope in JSON Pointer format
474
- const fieldPath = parentPath ? `${parentPath}/${key}` : key
475
- const scope = `#/${fieldPath}`
476
- const value = this.getValue(fieldPath)
565
+ /**
566
+ * Resolve number input type (range when both min and max are set, otherwise number)
567
+ * @private
568
+ */
569
+ #resolveNumberType(props) {
570
+ return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
571
+ }
477
572
 
478
- // Handle nested elements (arrays and objects)
479
- if (element.elements) {
480
- // This is a nested structure, process children
481
- const nestedElements = element.elements.map((child) =>
482
- this.#convertToFormElement(child, fieldPath)
483
- )
484
-
485
- // Group elements have top-level properties (label, etc.) from combineNestedElementsWithSchema
486
- const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
487
-
488
- return {
489
- scope,
490
- type: 'group',
491
- value,
492
- override: element.override || false,
493
- props: {
494
- ...topLevelProps,
495
- ...groupProps,
496
- elements: nestedElements,
497
- message: this.#validation[fieldPath] || null
498
- }
573
+ /**
574
+ * Resolve input type for string schema type
575
+ * @private
576
+ */
577
+ #resolveStringType(props) {
578
+ if (props.enum || props.options) {
579
+ // Map enum values to options format expected by select inputs
580
+ if (Array.isArray(props.enum) && !props.options) {
581
+ props.options = props.enum
499
582
  }
583
+ return 'select'
500
584
  }
585
+ return 'text'
586
+ }
501
587
 
502
- // Readonly fields render as info display
503
- if (props.readonly) {
504
- const validationMessage = this.#validation[fieldPath] || null
505
- return {
506
- scope,
507
- type: 'info',
508
- value,
509
- override: element.override || false,
510
- props: { ...props, type: 'info', message: validationMessage }
511
- }
512
- }
588
+ /**
589
+ * Resolve input type from schema type field
590
+ * @private
591
+ */
592
+ #resolveTypeFromSchema(props) {
593
+ if (props.type === 'number' || props.type === 'integer') return this.#resolveNumberType(props)
594
+ if (props.type === 'string') return this.#resolveStringType(props)
595
+ return SCHEMA_TYPE_MAP[props.type] ?? 'text'
596
+ }
513
597
 
514
- // Determine input type — renderer hint takes priority
515
- let type = 'text'
516
- if (props.renderer) {
517
- // Explicit renderer override — use as-is, resolveRenderer handles lookup
518
- type = props.renderer
519
- } else if (props.format && !['text', 'number'].includes(props.format)) {
520
- // Format hint maps to input type (email, url, tel, color, date, etc.)
521
- type = props.format
522
- } else if (props.type) {
523
- switch (props.type) {
524
- case 'number':
525
- case 'integer':
526
- type = props.min !== undefined && props.max !== undefined ? 'range' : 'number'
527
- break
528
- case 'boolean':
529
- type = 'checkbox'
530
- break
531
- case 'string':
532
- if (props.enum || props.options) {
533
- type = 'select'
534
- // Map enum values to options format expected by select inputs
535
- if (Array.isArray(props.enum) && !props.options) {
536
- props.options = props.enum
537
- }
538
- } else {
539
- type = 'text'
540
- }
541
- break
542
- case 'array':
543
- type = 'array'
544
- break
545
- }
546
- }
598
+ /**
599
+ * Resolve the input type from element props
600
+ * @private
601
+ * @param {Object} props - Element props
602
+ * @returns {string}
603
+ */
604
+ #resolveInputType(props) {
605
+ if (props.renderer) return props.renderer
606
+ if (props.format && !['text', 'number'].includes(props.format)) return props.format
607
+ return this.#resolveTypeFromSchema(props)
608
+ }
547
609
 
548
- // Add validation message and dirty state
549
- const validationMessage = this.#validation[fieldPath] || null
610
+ /**
611
+ * Apply lookup state into finalProps (mutates finalProps)
612
+ * @private
613
+ */
614
+ #applyLookupState(fieldPath, finalProps) {
615
+ const lookupState = this.getLookupState(fieldPath)
616
+ if (!lookupState) return
617
+ applyLookupProps(lookupState, finalProps)
618
+ }
550
619
 
551
- // Compose final props
620
+ /**
621
+ * Build a standard (non-nested, non-readonly) form element
622
+ * @private
623
+ */
624
+ #buildStandardElement(element, fieldPath, scope, value) {
625
+ const { props } = element
626
+ const type = this.#resolveInputType(props)
552
627
  const finalProps = {
553
628
  ...props,
554
629
  type,
555
- message: validationMessage,
630
+ message: this.#validation[fieldPath] || null,
556
631
  dirty: this.isFieldDirty(fieldPath)
557
632
  }
633
+ this.#applyLookupState(fieldPath, finalProps)
634
+ return { scope, type, value, override: element.override || false, props: finalProps }
635
+ }
558
636
 
559
- // Inject lookup state (options, loading, disabled, fields) when present
560
- const lookupState = this.getLookupState(fieldPath)
561
- if (lookupState) {
562
- if (lookupState.options?.length > 0) finalProps.options = lookupState.options
563
- if (lookupState.loading) finalProps.loading = true
564
- if (lookupState.disabled) finalProps.disabled = true
565
- if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
566
- }
637
+ /**
638
+ * Resolve the field path for an element
639
+ * @private
640
+ */
641
+ #resolveFieldPath(key, parentPath) {
642
+ return parentPath ? `${parentPath}/${key}` : key
643
+ }
567
644
 
568
- return {
569
- scope,
570
- type,
571
- value,
572
- override: element.override || false,
573
- props: finalProps
574
- }
645
+ /**
646
+ * Convert a combined schema/layout element to FormElement format
647
+ * @private
648
+ * @param {Object} element - Combined element from getSchemaWithLayout
649
+ * @param {string} parentPath - Parent path for nested elements
650
+ * @returns {FormElement} Form element
651
+ */
652
+ #convertToFormElement(element, parentPath = '') {
653
+ const { key } = element
654
+ if (!key) return null
655
+
656
+ const fieldPath = this.#resolveFieldPath(key, parentPath)
657
+ const scope = `#/${fieldPath}`
658
+ const value = this.getValue(fieldPath)
659
+
660
+ if (element.elements) return this.#convertNestedElement(element, fieldPath, scope, value)
661
+ if (element.props.readonly) return this.#convertReadonlyElement(element, fieldPath, scope, value)
662
+ return this.#buildStandardElement(element, fieldPath, scope, value)
575
663
  }
576
664
 
577
665
  /**
@@ -620,7 +708,7 @@ export class FormBuilder {
620
708
  */
621
709
  validate() {
622
710
  const results = validateAllFields(this.#data, this.#schema, this.#layout)
623
- const visiblePaths = new Set(
711
+ const visiblePaths = new SvelteSet(
624
712
  this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
625
713
  )
626
714
  const filtered = Object.fromEntries(
@@ -637,7 +725,7 @@ export class FormBuilder {
637
725
  * @returns {Object} Filtered data containing only visible field keys
638
726
  */
639
727
  getVisibleData() {
640
- const visiblePaths = new Set(
728
+ const visiblePaths = new SvelteSet(
641
729
  this.elements
642
730
  .filter((el) => el.scope)
643
731
  .map((el) => el.scope.replace(/^#\//, ''))
@@ -746,28 +834,17 @@ export class FormBuilder {
746
834
  * @param {Set<string>} dirty - Accumulator set
747
835
  */
748
836
  #collectDirtyFields(current, initial, prefix, dirty) {
749
- const allKeys = new Set([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
750
-
837
+ const allKeys = new SvelteSet([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
751
838
  for (const key of allKeys) {
752
- const path = prefix ? `${prefix}/${key}` : key
753
- const curVal = current?.[key]
754
- const initVal = initial?.[key]
755
-
756
- if (!deepEqual(curVal, initVal)) {
757
- dirty.add(path)
758
- }
839
+ collectIfDirty({ current, initial, prefix, key, dirty })
759
840
  }
760
841
  }
761
842
 
762
843
  /**
763
- * Get schema definition for a field path
844
+ * Walk schema properties following the key path
764
845
  * @private
765
- * @param {string} fieldPath - Field path
766
- * @returns {Object|null} Field schema
767
846
  */
768
- #getFieldSchema(fieldPath) {
769
- if (!this.#schema?.properties) return null
770
- const keys = fieldPath.split('/')
847
+ #walkSchemaPath(keys) {
771
848
  let current = this.#schema.properties
772
849
  for (const key of keys) {
773
850
  if (current && current[key]) {
@@ -779,6 +856,17 @@ export class FormBuilder {
779
856
  return current
780
857
  }
781
858
 
859
+ /**
860
+ * Get schema definition for a field path
861
+ * @private
862
+ * @param {string} fieldPath - Field path
863
+ * @returns {Object|null} Field schema
864
+ */
865
+ #getFieldSchema(fieldPath) {
866
+ if (!this.#schema?.properties) return null
867
+ return this.#walkSchemaPath(fieldPath.split('/'))
868
+ }
869
+
782
870
  /**
783
871
  * Get label for a field from layout
784
872
  * @private
@@ -788,7 +876,7 @@ export class FormBuilder {
788
876
  #getFieldLabel(fieldPath) {
789
877
  const scope = `#/${fieldPath}`
790
878
  const layoutEl = this.#layout?.elements?.find((el) => el.scope === scope)
791
- return layoutEl?.label || layoutEl?.title || fieldPath
879
+ return layoutElLabel(layoutEl, fieldPath)
792
880
  }
793
881
 
794
882
  /**
@@ -799,3 +887,51 @@ export class FormBuilder {
799
887
  this.#validation = {}
800
888
  }
801
889
  }
890
+
891
+ // ── Module-level helpers (no `this`) ──────────────────────────────────────────
892
+
893
+ /**
894
+ * Return the display label for a layout element, falling back to fieldPath
895
+ * @private
896
+ */
897
+ function layoutElLabel(layoutEl, fieldPath) {
898
+ if (layoutEl?.label) return layoutEl.label
899
+ return layoutEl?.title ?? fieldPath
900
+ }
901
+
902
+ /**
903
+ * Apply options/loading state from lookup to finalProps
904
+ * @private
905
+ */
906
+ function applyLookupData(lookupState, finalProps) {
907
+ if (lookupState.options?.length > 0) finalProps.options = lookupState.options
908
+ if (lookupState.loading) finalProps.loading = true
909
+ }
910
+
911
+ /**
912
+ * Apply disabled/fields state from lookup to finalProps
913
+ * @private
914
+ */
915
+ function applyLookupMeta(lookupState, finalProps) {
916
+ if (lookupState.disabled) finalProps.disabled = true
917
+ if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
918
+ }
919
+
920
+ /**
921
+ * Merge all lookup properties into finalProps
922
+ * @private
923
+ */
924
+ function applyLookupProps(lookupState, finalProps) {
925
+ applyLookupData(lookupState, finalProps)
926
+ applyLookupMeta(lookupState, finalProps)
927
+ }
928
+
929
+ /**
930
+ * Add path to dirty set if current and initial values differ
931
+ * @private
932
+ * @param {{ current: any, initial: any, prefix: string, key: string, dirty: Set<string> }} ctx
933
+ */
934
+ function collectIfDirty({ current, initial, prefix, key, dirty }) {
935
+ const path = prefix ? `${prefix}/${key}` : key
936
+ if (!deepEqual(current?.[key], initial?.[key])) dirty.add(path)
937
+ }