@rokkit/forms 1.7.0 → 1.8.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.
Files changed (32) hide show
  1. package/README.md +11 -4
  2. package/dist/lib/builder.svelte.d.ts +100 -248
  3. package/dist/lib/builder.svelte.js +182 -1047
  4. package/dist/lib/elements.d.ts +81 -0
  5. package/dist/lib/elements.js +196 -0
  6. package/dist/lib/fields.js +14 -1
  7. package/dist/lib/schema.d.ts +25 -0
  8. package/dist/lib/schema.js +46 -0
  9. package/dist/lib/state/FormDefinition.svelte.d.ts +32 -0
  10. package/dist/lib/state/FormDefinition.svelte.js +83 -0
  11. package/dist/lib/state/FormLookups.svelte.d.ts +39 -0
  12. package/dist/lib/state/FormLookups.svelte.js +93 -0
  13. package/dist/lib/state/FormSteps.svelte.d.ts +32 -0
  14. package/dist/lib/state/FormSteps.svelte.js +80 -0
  15. package/dist/lib/state/FormValidation.svelte.d.ts +52 -0
  16. package/dist/lib/state/FormValidation.svelte.js +140 -0
  17. package/dist/lib/state/FormValues.svelte.d.ts +29 -0
  18. package/dist/lib/state/FormValues.svelte.js +69 -0
  19. package/dist/lib/validation.js +17 -30
  20. package/dist/lib/values.d.ts +42 -0
  21. package/dist/lib/values.js +90 -0
  22. package/package.json +1 -1
  23. package/dist/lib/conditions.spec.d.ts +0 -1
  24. package/dist/lib/conditions.spec.js +0 -58
  25. package/dist/lib/fields.spec.d.ts +0 -1
  26. package/dist/lib/fields.spec.js +0 -250
  27. package/dist/lib/fixtures/combined-schema-layout.json +0 -64
  28. package/dist/lib/fixtures/input-layout.json +0 -68
  29. package/dist/lib/fixtures/input-schema.json +0 -16
  30. package/dist/lib/fixtures/result-with-generated-layout.json +0 -64
  31. package/dist/lib/fixtures/result-with-generated-schema.json +0 -60
  32. package/dist/lib/fixtures/result-with-only-data.json +0 -60
@@ -1,1128 +1,263 @@
1
- import { SvelteMap, SvelteSet } from 'svelte/reactivity'
2
- import { deriveSchemaFromValue } from './schema.js'
3
- import { deriveLayoutFromValue, deriveLayoutFromSchema } from './layout.js'
4
- import { getSchemaWithLayout } from './fields.js'
5
- import { createLookupManager } from './lookup.svelte.js'
6
- import { evaluateCondition } from './conditions.js'
7
- import {
8
- validateField as validateFieldValue,
9
- validateAll as validateAllFields
10
- } from './validation.js'
1
+ import { SvelteSet } from 'svelte/reactivity'
2
+ import { buildElements } from './elements.js'
3
+ import { toFieldPath } from './values.js'
4
+ import { FormValues } from './state/FormValues.svelte.js'
5
+ import { FormDefinition } from './state/FormDefinition.svelte.js'
6
+ import { FormSteps } from './state/FormSteps.svelte.js'
7
+ import { FormValidation } from './state/FormValidation.svelte.js'
8
+ import { FormLookups } from './state/FormLookups.svelte.js'
11
9
 
12
10
  /**
13
- * Deep-clone a plain value (primitives, plain objects, arrays).
14
- * Uses JSON round-trip which handles $state proxies safely.
15
- * @param {any} value
16
- * @returns {any}
17
- */
18
- function deepClone(value) {
19
- if (value === null || value === undefined || typeof value !== 'object') return value
20
- return JSON.parse(JSON.stringify(value))
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
-
52
- /**
53
- * Deep equality check for plain values (primitives, plain objects, arrays).
54
- * @param {any} a
55
- * @param {any} b
56
- * @returns {boolean}
57
- */
58
- function deepEqual(a, b) {
59
- if (a === b) return true
60
- if (isNullish(a) || isNullish(b)) return false
61
- return areSameObjectType(a, b) && deepEqualObjects2(a, b)
62
- }
63
-
64
- /**
65
- * @typedef {Object} FormElement
66
- * @property {string} scope - JSON Pointer path (e.g., '#/email', '#/user/name')
67
- * @property {string} type - Input type (text, number, range, checkbox, select, etc.)
68
- * @property {any} value - Current value from data
69
- * @property {boolean} override - Whether to use custom child snippet (from layout)
70
- * @property {Object} props - Merged properties from schema + layout + validation
71
- * @property {string} [props.label] - Display label (from layout)
72
- * @property {string} [props.description] - Help text (from layout)
73
- * @property {string} [props.placeholder] - Placeholder text (from layout)
74
- * @property {boolean} [props.required] - Required flag (from schema)
75
- * @property {number} [props.min] - Minimum value (from schema)
76
- * @property {number} [props.max] - Maximum value (from schema)
77
- * @property {Object} [props.message] - Validation message object
78
- * @property {string} [props.message.state] - Message state: 'error', 'warning', 'info', 'success'
79
- * @property {string} [props.message.text] - Message text content
80
- * @property {boolean} [props.dirty] - Whether field value differs from initial
81
- */
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
-
105
- /**
106
- * FormBuilder class for dynamically generating forms from data structures
11
+ * The state behind a schema-driven form — a COMPOSITION of job classes (`./state/`), each
12
+ * owning one concern:
13
+ *
14
+ * values → definition → steps → validation → lookups, and `elements` derived from them all
15
+ * by the pure `buildElements` (`./elements.js`).
16
+ *
17
+ * It holds almost no logic itself: `next()` (validate the step, then advance), `updateField()`
18
+ * (write, drop hidden messages, re-fetch dependent lookups) and `getVisibleData()` are the only
19
+ * methods that span two jobs. Everything else is the public API FormRenderer, MultiStep and
20
+ * consumers use, kept as delegations so none of them changed when the class was taken apart.
107
21
  */
108
22
  export class FormBuilder {
109
- /** @type {Record<string, unknown>} */
110
- #data = $state({})
111
-
112
- /** @type {Record<string, unknown>} - Snapshot of data at construction (or last snapshot()) */
113
- #initialData = {}
114
-
115
- /** @type {Object} */
116
- #schema = $state({})
117
-
118
- /** @type {Object} */
119
- #layout = $state({})
120
-
121
- /** @type {Object} */
122
- #validation = $state({})
123
-
124
- /** @type {Object<string, import('./lookup.svelte.js').LookupConfig>} */
125
- #lookupConfigs = $state({})
126
-
127
- /** @type {ReturnType<typeof createLookupManager>|null} */
128
- #lookupManager = $state(null)
129
-
130
- /** @type {number} */
131
- #currentStep = $state(0)
132
-
133
- /** @type {FormElement[]} */
134
- elements = $derived(this.#buildElements())
135
-
136
- /** True when currentStep can advance to the next step */
137
- canAdvance = $derived(this.#currentStep < this.totalSteps - 1)
138
-
139
- /** Combined schema+layout (scoped elements only) */
140
- get combined() {
141
- const scopedElements = (this.#layout?.elements ?? []).filter((el) => el.scope)
142
- const scopedLayout = { ...this.#layout, elements: scopedElements }
143
- return getSchemaWithLayout(this.#schema, scopedLayout)
144
- }
145
- /**
146
- * Get the current data
147
- * @returns {Record<string, unknown>} Current data object
148
- */
149
- get data() {
150
- return this.#data
151
- }
152
-
153
- /**
154
- * Set the data
155
- * @param {Record<string, unknown>} value - New data object
156
- */
157
- set data(value) {
158
- this.#data = value
159
- }
160
-
161
- /**
162
- * Get the current schema
163
- * @returns {Object} Current schema object
164
- */
165
- get schema() {
166
- return this.#schema
167
- }
168
-
169
- /**
170
- * Set the schema
171
- * @param {Object} value - New schema object
172
- *
173
- * When the caller explicitly provides a schema, that schema is the source
174
- * of truth for which fields exist — the layout may have been derived
175
- * earlier from empty/partial data and would silently hide those fields.
176
- * Re-derive the layout from the new schema so the two stay consistent,
177
- * unless the caller has already installed an explicit non-empty layout.
178
- */
179
- set schema(value) {
180
- this.#schema = value ?? deriveSchemaFromValue(this.#data)
181
- const layout = this.#layout
182
- const hasExplicitLayout = layout && Array.isArray(layout.elements) && layout.elements.length > 0
183
- if (!hasExplicitLayout) {
184
- this.#layout = deriveLayoutFromSchema(this.#schema)
185
- }
186
- }
187
-
188
- /**
189
- * Get the current layout
190
- * @returns {Object} Current layout object
191
- */
192
- get layout() {
193
- return this.#layout
194
- }
195
-
196
- /**
197
- * Set the layout
198
- * @param {Object} value - New layout object
199
- */
200
- set layout(value) {
201
- if (value) {
202
- this.#layout = value
203
- return
204
- }
205
- // No explicit layout — prefer schema-derived over data-derived so
206
- // declared fields render even when initial data is `{}` (a common
207
- // pattern for LLM-generated forms and reset forms).
208
- const schema = this.#schema
209
- const hasSchemaProperties =
210
- schema && typeof schema === 'object' && schema.properties &&
211
- typeof schema.properties === 'object' &&
212
- Object.keys(schema.properties).length > 0
213
- this.#layout = hasSchemaProperties
214
- ? deriveLayoutFromSchema(schema)
215
- : deriveLayoutFromValue(this.#data)
216
- }
217
-
218
- /**
219
- * Get the current validation state
220
- * @returns {Object} Current validation object
221
- */
222
- get validation() {
223
- return this.#validation
224
- }
23
+ /** The form's data and its dirty baseline. */
24
+ values
25
+ /** Schema and layout, and how each derives from the other. */
26
+ definition
27
+ /** Multi-step navigation. */
28
+ steps
29
+ /** Validation messages, and the ways of producing them. Suffixed: `validation` is the public map. */
30
+ validationState
31
+ /** Fields whose options come from elsewhere, and what they depend on. */
32
+ lookups
225
33
 
226
- /**
227
- * Set validation messages for fields
228
- * @param {Object} value - Validation object with field paths as keys
229
- */
230
- set validation(value) {
231
- this.#validation = value
232
- }
34
+ /** @type {import('./elements.js').FormElement[]} */
35
+ elements = $derived(buildElements(this.steps.activeElements, this.#elementContext()))
233
36
 
234
37
  /**
235
- * Initialise the lookup manager if any lookups are provided
236
- * @private
237
- */
238
- #initLookups(lookups) {
239
- this.#lookupConfigs = lookups
240
- if (Object.keys(lookups).length > 0) {
241
- this.#lookupManager = createLookupManager(lookups)
242
- }
243
- }
244
-
245
- /**
246
- * Create a new FormBuilder instance
247
38
  * @param {Record<string, unknown>} [data={}] - Initial data object
248
- * @param {Object|null} [schema=null] - Optional schema override
249
- * @param {Object|null} [layout=null] - Optional layout override
39
+ * @param {Object|null} [schema=null] - Schema; derived from the data when omitted
40
+ * @param {Object|null} [layout=null] - Layout; derived from the schema, else the data, when omitted
250
41
  * @param {Object<string, import('./lookup.svelte.js').LookupConfig>} [lookups={}] - Lookup configurations
251
42
  */
252
43
  constructor(data = {}, schema = null, layout = null, lookups = {}) {
253
- this.#data = data
254
- this.#initialData = deepClone(data)
255
- this.schema = schema
256
- this.layout = layout
257
- this.#initLookups(lookups)
258
- }
259
-
260
- /**
261
- * Get the lookup manager
262
- * @returns {ReturnType<typeof createLookupManager>|null}
263
- */
264
- get lookupManager() {
265
- return this.#lookupManager
266
- }
267
-
268
- /**
269
- * Configure lookups for the form
270
- * @param {Object<string, import('./lookup.svelte.js').LookupConfig>} lookups - Lookup configurations
271
- */
272
- setLookups(lookups) {
273
- this.#lookupConfigs = lookups
274
- this.#lookupManager = createLookupManager(lookups)
275
- }
276
-
277
- /**
278
- * Get lookup state for a field
279
- * @param {string} fieldPath - Field path
280
- * @returns {{ options: any[], loading: boolean, error: string|null, fields: Object, disabled: boolean }|null}
281
- */
282
- getLookupState(fieldPath) {
283
- if (!this.#lookupManager) return null
284
- const lookup = this.#lookupManager.getLookup(fieldPath)
285
- if (!lookup) return null
286
- return {
287
- options: lookup.options,
288
- loading: lookup.loading,
289
- error: lookup.error,
290
- fields: lookup.fields,
291
- disabled: lookup.disabled
292
- }
293
- }
294
-
295
- /**
296
- * Check if a field is disabled due to unmet lookup dependencies
297
- * @param {string} path - Field path
298
- * @returns {boolean}
299
- */
300
- isFieldDisabled(path) {
301
- return this.#lookupManager?.getLookup(path)?.disabled ?? false
302
- }
303
-
304
- /**
305
- * Manually refresh a field's lookup with the current form data
306
- * @param {string} path - Field path
307
- * @returns {Promise<void>}
308
- */
309
- async refreshLookup(path) {
310
- const lookup = this.#lookupManager?.getLookup(path)
311
- if (lookup) await lookup.fetch(this.#data)
312
- }
313
-
314
- /**
315
- * Check if a field has a lookup configured
316
- * @param {string} fieldPath - Field path
317
- * @returns {boolean}
318
- */
319
- hasLookup(fieldPath) {
320
- return this.#lookupManager?.hasLookup(fieldPath) ?? false
321
- }
322
-
323
- /**
324
- * Initialize all lookups
325
- * @returns {Promise<void>}
326
- */
327
- async initializeLookups() {
328
- if (this.#lookupManager) {
329
- await this.#lookupManager.initialize(this.#data)
330
- }
331
- }
332
-
333
- /**
334
- * Clear dependent field values for a changed path
335
- * @private
336
- */
337
- #clearDependentFields(path) {
338
- for (const [depPath, lookup] of this.#lookupManager.lookups) {
339
- if (!lookup.dependsOn.includes(path)) continue
340
- const depKeys = depPath.split('/')
341
- if (depKeys.length === 1) {
342
- this.#data = { ...this.#data, [depKeys[0]]: null }
343
- }
344
- }
44
+ this.values = new FormValues(data)
45
+ this.definition = new FormDefinition(this.values, schema, layout)
46
+ this.steps = new FormSteps(this.definition)
47
+ this.validationState = new FormValidation({
48
+ values: this.values,
49
+ definition: this.definition,
50
+ steps: this.steps,
51
+ visiblePaths: () => this.#visiblePaths()
52
+ })
53
+ this.lookups = new FormLookups(this.values, lookups)
345
54
  }
346
55
 
347
56
  /**
348
- * Update a specific field value
349
- * @param {string} path - Field path (e.g., 'count', 'settings/distance')
350
- * @param {any} value - New value
351
- * @param {boolean} [triggerLookups=true] - Whether to trigger dependent lookups
57
+ * The field paths that currently render — a `showWhen`-hidden field has no element. A group's
58
+ * children count: their messages must survive `validate()` and `clearHidden()`.
352
59
  */
353
- updateField(path, value, triggerLookups = true) {
354
- const keys = path.split('/')
355
- if (keys.length === 1) {
356
- this.#data = { ...this.#data, [keys[0]]: value }
357
- } else {
358
- this.#data = setNestedValue(this.#data, keys, value)
359
- }
360
-
361
- // Clear stale validation errors for fields that are now hidden
362
- this.#clearHiddenValidation()
363
-
364
- // Trigger dependent lookups if configured
365
- if (triggerLookups && this.#lookupManager) {
366
- this.#clearDependentFields(path)
367
- this.#lookupManager.handleFieldChange(path, this.#data)
368
- }
369
- }
370
-
371
- /**
372
- * Get a field value by path
373
- * @param {string} path - Field path
374
- * @returns {any} Field value
375
- */
376
- getValue(path) {
377
- if (!path) return undefined
378
-
379
- const keys = path.split('/')
380
- let current = this.#data
381
- for (const key of keys) {
382
- if (current && typeof current === 'object') {
383
- current = current[key]
384
- } else {
385
- return undefined
60
+ #visiblePaths() {
61
+ const paths = []
62
+ const walk = (elements) => {
63
+ for (const el of elements) {
64
+ if (el?.scope) paths.push(el.scope.replace(/^#\//, ''))
65
+ if (el?.type === 'group') walk(el.props.elements ?? [])
386
66
  }
387
67
  }
388
- return current
389
- }
390
-
391
- /**
392
- * Clear validation errors for fields that are no longer visible
393
- * @private
394
- */
395
- #clearHiddenValidation() {
396
- const visiblePaths = new SvelteSet(
397
- this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
398
- )
399
- const cleaned = Object.fromEntries(
400
- Object.entries(this.#validation).filter(([path]) => visiblePaths.has(path))
401
- )
402
- if (Object.keys(cleaned).length !== Object.keys(this.#validation).length) {
403
- this.#validation = cleaned
404
- }
405
- }
406
-
407
- /**
408
- * Build a display element from the layout entry
409
- * @private
410
- */
411
- #buildDisplayElement(layoutEl) {
412
- const scope = layoutEl.scope ?? null
413
- const fieldPath = scope?.replace(/^#\//, '')
414
- const value = fieldPath ? this.getValue(fieldPath) : null
415
- const { type: displayType, scope: _s, ...displayProps } = layoutEl
416
- return { type: displayType, scope, value, override: false, props: displayProps }
68
+ walk(this.elements)
69
+ return paths
417
70
  }
418
71
 
419
72
  /**
420
- * Build a non-scoped separator/spacer element from the layout entry
421
- * @private
73
+ * What the element builder reads. Getters, so the `elements` derivation tracks exactly the
74
+ * state it touches.
75
+ * @returns {import('./elements.js').ElementContext}
422
76
  */
423
- #buildSeparatorElement(layoutEl) {
424
- const { type: separatorType, ...separatorProps } = layoutEl
77
+ #elementContext() {
78
+ const { definition, values, validationState, lookups } = this
425
79
  return {
426
- type: separatorType ?? 'separator',
427
- scope: null,
428
- value: null,
429
- override: false,
430
- props: separatorProps
431
- }
80
+ get schema() {
81
+ return definition.schema
82
+ },
83
+ get layout() {
84
+ return definition.layout
85
+ },
86
+ get data() {
87
+ return values.data
88
+ },
89
+ value: (path) => values.get(path),
90
+ message: (path) => validationState.message(path),
91
+ dirty: (path) => values.isFieldDirty(path),
92
+ applyLookup: (path, props) => lookups.applyTo(path, props)
93
+ }
94
+ }
95
+
96
+ // ─── values ────────────────────────────────────────────────────────────────
97
+ get data() {
98
+ return this.values.data
432
99
  }
433
-
434
- /**
435
- * Process one scoped layout element — returns FormElement or null
436
- * @private
437
- */
438
- #processScopedElement(layoutEl, combinedMap) {
439
- if (layoutEl.showWhen && !evaluateCondition(layoutEl.showWhen, this.#data)) return null
440
- const key = layoutEl.scope.replace(/^#\//, '').split('/').pop()
441
- const combinedEl = combinedMap.get(key)
442
- return combinedEl ? this.#convertToFormElement(combinedEl) : null
100
+ set data(value) {
101
+ this.values.data = value
443
102
  }
444
-
445
103
  /**
446
- * Build the combined element map from schema+layout
447
- * @private
104
+ * Every method taking a field path accepts a bare slash path (`'settings/distance'`) or a
105
+ * JSON Forms scope (`'#/settings/distance'`) — see `toFieldPath`.
448
106
  */
449
- #buildCombinedMap(layoutElements) {
450
- const scopedElements = layoutElements.filter(
451
- (el) => el.scope && !el.type?.startsWith('display-')
452
- )
453
- const scopedLayout = { ...this.#layout, elements: scopedElements }
454
- const combined = getSchemaWithLayout(this.#schema, scopedLayout)
455
-
456
- const combinedMap = new SvelteMap()
457
- for (const el of combined.elements ?? []) {
458
- if (el.key) combinedMap.set(el.key, el)
459
- }
460
- return combinedMap
107
+ getValue(path) {
108
+ return this.values.get(toFieldPath(path))
461
109
  }
462
-
463
110
  /**
464
- * Convert one layout element to a form element (or null)
465
- * @private
111
+ * Write a field, then drop messages of fields a `showWhen` just hid, then clear and re-fetch
112
+ * the lookups that depend on it.
113
+ * @param {string} path
114
+ * @param {any} value
115
+ * @param {boolean} [triggerLookups=true]
466
116
  */
467
- #buildOneElement(layoutEl, combinedMap) {
468
- if (layoutEl.type?.startsWith('display-')) return this.#buildDisplayElement(layoutEl)
469
- if (!layoutEl.scope) return this.#buildSeparatorElement(layoutEl)
470
- return this.#processScopedElement(layoutEl, combinedMap)
117
+ updateField(scopeOrPath, value, triggerLookups = true) {
118
+ const path = toFieldPath(scopeOrPath)
119
+ this.values.set(path, value)
120
+ this.validationState.clearHidden()
121
+ if (triggerLookups) this.lookups.fieldChanged(path)
471
122
  }
472
-
473
- /**
474
- * Collect form elements from layout into result array
475
- * @private
476
- */
477
- #collectElements(layoutElements, combinedMap) {
478
- const result = []
479
- for (const layoutEl of layoutElements) {
480
- const formEl = this.#buildOneElement(layoutEl, combinedMap)
481
- if (formEl) result.push(formEl)
482
- }
483
- return result
484
- }
485
-
486
- /**
487
- * Build form elements from schema and layout using getSchemaWithLayout
488
- * @private
489
- * @returns {FormElement[]} Array of form elements
490
- */
491
- #buildElements() {
492
- try {
493
- const layoutElements = this.#getActiveElements()
494
- const combinedMap = this.#buildCombinedMap(layoutElements)
495
- return this.#collectElements(layoutElements, combinedMap)
496
- } catch (error) {
497
- console.warn('Failed to build elements:', error) // eslint-disable-line no-console
498
- return this.#buildBasicElements()
499
- }
123
+ /** The data without hidden fields' values. Does not mutate the data. */
124
+ getVisibleData() {
125
+ const visible = new SvelteSet(this.#visiblePaths())
126
+ return Object.fromEntries(Object.entries(this.values.data).filter(([key]) => visible.has(key)))
500
127
  }
501
-
502
- /**
503
- * Build basic form elements when getSchemaWithLayout fails
504
- * @private
505
- * @returns {FormElement[]} Array of form elements
506
- */
507
- #buildBasicElements() {
508
- const elements = []
509
-
510
- if (this.#layout.elements) {
511
- for (const layoutElement of this.#layout.elements) {
512
- const formElement = this.#buildBasicElement(layoutElement)
513
- if (formElement) {
514
- elements.push(formElement)
515
- }
516
- }
517
- }
518
-
519
- return elements
128
+ get isDirty() {
129
+ return this.values.isDirty
520
130
  }
521
-
522
- /**
523
- * Build a basic form element from layout only
524
- * @private
525
- * @param {Object} layoutElement - Layout element definition
526
- * @returns {FormElement|null} Form element or null
527
- */
528
- #buildBasicElement(layoutElement) {
529
- const { scope, label, override = false, ...layoutProps } = layoutElement
530
-
531
- if (!scope) return null
532
-
533
- // Extract field name from scope (remove leading '#/')
534
- const fieldPath = scope.replace(/^#\//, '')
535
- const value = this.getValue(fieldPath)
536
-
537
- // Default type is text when no schema is available
538
- const type = 'text'
539
-
540
- // Basic props
541
- const props = {
542
- label: label || fieldPath,
543
- ...layoutProps,
544
- message: this.#validation[fieldPath] || null,
545
- dirty: this.isFieldDirty(fieldPath),
546
- type
547
- }
548
-
549
- return {
550
- scope,
551
- type,
552
- value,
553
- override,
554
- props
555
- }
131
+ /** Top-level fields that differ from the initial snapshot. */
132
+ get dirtyFields() {
133
+ return this.values.dirtyFields
556
134
  }
557
-
558
- /**
559
- * Convert a nested (group) element to FormElement format
560
- * @private
561
- */
562
- #convertNestedElement(element, fieldPath, scope, value) {
563
- const nestedElements = element.elements.map((child) =>
564
- this.#convertToFormElement(child, fieldPath)
565
- )
566
- const { key: _k, elements: _e, override: _o, props: groupProps, ...topLevelProps } = element
567
- return {
568
- scope,
569
- type: 'group',
570
- value,
571
- override: element.override || false,
572
- props: {
573
- ...topLevelProps,
574
- ...groupProps,
575
- elements: nestedElements,
576
- message: this.#validation[fieldPath] || null
577
- }
578
- }
135
+ isFieldDirty(fieldPath) {
136
+ return this.values.isFieldDirty(toFieldPath(fieldPath))
579
137
  }
580
-
581
- /**
582
- * Convert a readonly element to FormElement format
583
- * @private
584
- */
585
- #convertReadonlyElement(element, fieldPath, scope, value) {
586
- const validationMessage = this.#validation[fieldPath] || null
587
- return {
588
- scope,
589
- type: 'info',
590
- value,
591
- override: element.override || false,
592
- props: { ...element.props, type: 'info', message: validationMessage }
593
- }
138
+ /** Make the current data the baseline — after a successful save. */
139
+ snapshot() {
140
+ this.values.snapshot()
594
141
  }
595
-
596
- /**
597
- * Resolve number input type (range when both min and max are set, otherwise number)
598
- * @private
599
- */
600
- #resolveNumberType(props) {
601
- return props.min !== undefined && props.max !== undefined ? 'range' : 'number'
142
+ /** Restore the baseline and clear every message. */
143
+ reset() {
144
+ this.values.reset()
145
+ this.validationState.clear()
602
146
  }
603
147
 
604
- /**
605
- * Resolve input type for string schema type
606
- * @private
607
- */
608
- #resolveStringType(props) {
609
- if (props.enum || props.options) {
610
- // Map enum values to options format expected by select inputs
611
- if (Array.isArray(props.enum) && !props.options) {
612
- props.options = props.enum
613
- }
614
- return 'select'
615
- }
616
- return 'text'
148
+ // ─── definition ────────────────────────────────────────────────────────────
149
+ /** Setting it may re-derive the layout — see FormDefinition. */
150
+ get schema() {
151
+ return this.definition.schema
617
152
  }
618
-
619
- /**
620
- * Resolve input type from schema type field
621
- * @private
622
- */
623
- #resolveTypeFromSchema(props) {
624
- if (props.type === 'number' || props.type === 'integer') return this.#resolveNumberType(props)
625
- if (props.type === 'string') return this.#resolveStringType(props)
626
- return SCHEMA_TYPE_MAP[props.type] ?? 'text'
153
+ set schema(value) {
154
+ this.definition.schema = value
627
155
  }
628
-
629
- /**
630
- * Resolve the input type from element props
631
- * @private
632
- * @param {Object} props - Element props
633
- * @returns {string}
634
- */
635
- #resolveInputType(props) {
636
- if (props.renderer) return props.renderer
637
- if (props.format && !['text', 'number'].includes(props.format)) return props.format
638
- return this.#resolveTypeFromSchema(props)
156
+ /** A null layout derives from the schema, else the data. */
157
+ get layout() {
158
+ return this.definition.layout
639
159
  }
640
-
641
- /**
642
- * Apply lookup state into finalProps (mutates finalProps)
643
- * @private
644
- */
645
- #applyLookupState(fieldPath, finalProps) {
646
- const lookupState = this.getLookupState(fieldPath)
647
- if (!lookupState) return
648
- applyLookupProps(lookupState, finalProps)
160
+ set layout(value) {
161
+ this.definition.layout = value
649
162
  }
650
-
651
- /**
652
- * Build a standard (non-nested, non-readonly) form element
653
- * @private
654
- */
655
- #buildStandardElement(element, fieldPath, scope, value) {
656
- // `override` is authored on the LAYOUT element (documented in the README and
657
- // docs/llms as the way to opt a field into the consumer's `child` snippet).
658
- // getSchemaWithLayout folds unrecognised layout keys into `props`, so reading
659
- // only `element.override` left it permanently false and the child snippet
660
- // never rendered. Take it from either position, and keep it out of the props
661
- // handed to the input component.
662
- const { override: propsOverride, ...props } = element.props ?? {}
663
- const type = this.#resolveInputType(props)
664
- const finalProps = {
665
- ...props,
666
- type,
667
- message: this.#validation[fieldPath] || null,
668
- dirty: this.isFieldDirty(fieldPath)
669
- }
670
- this.#applyLookupState(fieldPath, finalProps)
671
- return {
672
- scope,
673
- type,
674
- value,
675
- override: element.override || propsOverride || false,
676
- props: finalProps
677
- }
163
+ /** Schema merged with the scoped layout elements. */
164
+ get combined() {
165
+ return this.definition.combined
678
166
  }
679
167
 
680
- /**
681
- * Resolve the field path for an element
682
- * @private
683
- */
684
- #resolveFieldPath(key, parentPath) {
685
- return parentPath ? `${parentPath}/${key}` : key
168
+ // ─── validation ────────────────────────────────────────────────────────────
169
+ /** The validation messages, by field path. */
170
+ get validation() {
171
+ return this.validationState.map
686
172
  }
687
-
688
- /**
689
- * Convert a combined schema/layout element to FormElement format
690
- * @private
691
- * @param {Object} element - Combined element from getSchemaWithLayout
692
- * @param {string} parentPath - Parent path for nested elements
693
- * @returns {FormElement} Form element
694
- */
695
- #convertToFormElement(element, parentPath = '') {
696
- const { key } = element
697
- /* v8 ignore start -- unreachable: both call sites source elements from the
698
- combined map, which #buildCombinedMap populates under `if (el.key)`, so a
699
- keyless element never arrives here. `ignore next` does not fire on a
700
- single-line `if (...) return`. */
701
- if (!key) return null
702
- /* v8 ignore stop */
703
-
704
- const fieldPath = this.#resolveFieldPath(key, parentPath)
705
- const scope = `#/${fieldPath}`
706
- const value = this.getValue(fieldPath)
707
-
708
- if (element.elements) return this.#convertNestedElement(element, fieldPath, scope, value)
709
- if (element.props.readonly)
710
- return this.#convertReadonlyElement(element, fieldPath, scope, value)
711
- return this.#buildStandardElement(element, fieldPath, scope, value)
173
+ set validation(value) {
174
+ this.validationState.map = value
712
175
  }
713
-
714
- /**
715
- * Set validation message for a specific field
716
- * @param {string} fieldPath - Field path (without '#/' prefix)
717
- * @param {Object|null} message - Validation message object or null to clear
718
- * @param {string} message.state - Message state: 'error', 'warning', 'info', 'success'
719
- * @param {string} message.text - Message text content
720
- */
176
+ /** @param {string} fieldPath @param {Object|null} message - null clears it */
721
177
  setFieldValidation(fieldPath, message) {
722
- if (message) {
723
- this.#validation = { ...this.#validation, [fieldPath]: message }
724
- } else {
725
- const { [fieldPath]: _, ...rest } = this.#validation
726
- this.#validation = rest
727
- }
178
+ this.validationState.set(toFieldPath(fieldPath), message)
728
179
  }
729
-
730
- /**
731
- * Clear all validation messages
732
- */
733
180
  clearValidation() {
734
- this.#validation = {}
181
+ this.validationState.clear()
735
182
  }
736
-
737
- /**
738
- * Validate a single field by path
739
- * @param {string} fieldPath - Field path (without '#/' prefix)
740
- * @returns {import('./validation.js').ValidationMessage|null} Validation result
741
- */
183
+ /** @returns {import('./validation.js').ValidationMessage|null} */
742
184
  validateField(fieldPath) {
743
- const fieldSchema = this.#getFieldSchema(fieldPath)
744
- if (!fieldSchema) return null
745
-
746
- const value = this.getValue(fieldPath)
747
- const label = this.#getFieldLabel(fieldPath)
748
- const result = validateFieldValue(value, fieldSchema, label)
749
-
750
- this.setFieldValidation(fieldPath, result)
751
- return result
185
+ return this.validationState.validateField(toFieldPath(fieldPath))
752
186
  }
753
-
754
- /**
755
- * Validate all fields, populate validation state
756
- * @returns {Object} Validation results keyed by field path
757
- */
187
+ /** Validate the whole form (every step of a wizard; the visible fields of a flat form). */
758
188
  validate() {
759
- if (this.isMultiStep) {
760
- // Flatten all step elements into a synthetic layout for full validation
761
- const allElements = []
762
- for (const step of this.#layout?.elements ?? []) {
763
- if (step.type === 'step') allElements.push(...(step.elements ?? []))
764
- }
765
- const flatLayout = { ...this.#layout, elements: allElements }
766
- const results = validateAllFields(this.#data, this.#schema, flatLayout)
767
- this.#validation = results
768
- return results
769
- }
770
- const results = validateAllFields(this.#data, this.#schema, this.#layout)
771
- const visiblePaths = new SvelteSet(
772
- this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
773
- )
774
- const filtered = Object.fromEntries(
775
- Object.entries(results).filter(([path]) => visiblePaths.has(path))
776
- )
777
- this.#validation = filtered
778
- return filtered
189
+ return this.validationState.validateAll()
779
190
  }
780
-
781
- /**
782
- * Get form data with hidden field values stripped out
783
- * Hidden fields are those absent from this.elements (the derived list)
784
- * Does not mutate this.#data
785
- * @returns {Record<string, unknown>} Filtered data containing only visible field keys
786
- */
787
- getVisibleData() {
788
- const visiblePaths = new SvelteSet(
789
- this.elements.filter((el) => el.scope).map((el) => el.scope.replace(/^#\//, ''))
790
- )
791
- return Object.fromEntries(Object.entries(this.#data).filter(([key]) => visiblePaths.has(key)))
191
+ /** Validate one step's fields. */
192
+ validateStep(index) {
193
+ return this.validationState.validateStep(index)
194
+ }
195
+ isStepValid(index = this.steps.current) {
196
+ return this.validateStep(index)
792
197
  }
793
-
794
- /**
795
- * Whether all fields pass validation (no error-state messages)
796
- * @returns {boolean}
797
- */
798
198
  get isValid() {
799
- return Object.values(this.#validation).every((msg) => msg.state !== 'error')
199
+ return this.validationState.isValid
800
200
  }
801
-
802
- /**
803
- * Array of current error messages with paths
804
- * @returns {Array<{path: string, state: string, text: string}>}
805
- */
201
+ /** @returns {Array<{path: string, state: string, text: string}>} */
806
202
  get errors() {
807
- return Object.entries(this.#validation)
808
- .filter(([, msg]) => msg.state === 'error')
809
- .map(([path, msg]) => ({ path, ...msg }))
203
+ return this.validationState.errors
810
204
  }
811
-
812
- /**
813
- * Array of all validation messages with paths, ordered by severity
814
- * @returns {Array<{path: string, state: string, text: string}>}
815
- */
205
+ /** @returns {Array<{path: string, state: string, text: string}>} most severe first */
816
206
  get messages() {
817
- const order = { error: 0, warning: 1, info: 2, success: 3 }
818
- return Object.entries(this.#validation)
819
- .filter(([, msg]) => msg !== null && msg !== undefined)
820
- .map(([path, msg]) => ({ path, ...msg }))
821
- .sort((a, b) => (order[a.state] ?? 4) - (order[b.state] ?? 4))
822
- }
823
-
824
- // ── Dirty Tracking ────────────────────────────────────────
825
-
826
- /**
827
- * Whether any field has been modified from its initial value
828
- * @returns {boolean}
829
- */
830
- get isDirty() {
831
- return !deepEqual(this.#data, this.#initialData)
207
+ return this.validationState.sorted
832
208
  }
833
209
 
834
- /**
835
- * Set of field paths that differ from their initial values
836
- * @returns {Set<string>}
837
- */
838
- get dirtyFields() {
839
- const dirty = new SvelteSet()
840
- this.#collectDirtyFields(this.#data, this.#initialData, '', dirty)
841
- return dirty
842
- }
843
-
844
- /**
845
- * Check if a single field has been modified from its initial value
846
- * @param {string} fieldPath - Field path (without '#/' prefix)
847
- * @returns {boolean}
848
- */
849
- isFieldDirty(fieldPath) {
850
- const current = this.getValue(fieldPath)
851
- const initial = this.#getInitialValue(fieldPath)
852
- return !deepEqual(current, initial)
853
- }
854
-
855
- /**
856
- * Update the initial data snapshot to the current data.
857
- * Call after a successful save to clear dirty state.
858
- */
859
- snapshot() {
860
- this.#initialData = deepClone(this.#data)
861
- }
862
-
863
- /**
864
- * Get a field's initial value by path
865
- * @private
866
- * @param {string} path - Field path
867
- * @returns {any} Initial field value
868
- */
869
- #getInitialValue(path) {
870
- if (!path) return undefined
871
-
872
- const keys = path.split('/')
873
- let current = this.#initialData
874
- for (const key of keys) {
875
- if (current && typeof current === 'object') {
876
- current = current[key]
877
- } else {
878
- return undefined
879
- }
880
- }
881
- return current
882
- }
883
-
884
- /**
885
- * Recursively collect dirty field paths by comparing current vs initial
886
- * @private
887
- * @param {any} current - Current data (sub)tree
888
- * @param {any} initial - Initial data (sub)tree
889
- * @param {string} prefix - Path prefix
890
- * @param {Set<string>} dirty - Accumulator set
891
- */
892
- #collectDirtyFields(current, initial, prefix, dirty) {
893
- const allKeys = new SvelteSet([...Object.keys(current ?? {}), ...Object.keys(initial ?? {})])
894
- for (const key of allKeys) {
895
- collectIfDirty({ current, initial, prefix, key, dirty })
896
- }
897
- }
898
-
899
- /**
900
- * Walk schema properties following the key path
901
- * @private
902
- */
903
- #walkSchemaPath(keys) {
904
- let current = this.#schema.properties
905
- for (const key of keys) {
906
- if (current && current[key]) {
907
- current = current[key]
908
- } else {
909
- return null
910
- }
911
- }
912
- return current
913
- }
914
-
915
- /**
916
- * Get schema definition for a field path
917
- * @private
918
- * @param {string} fieldPath - Field path
919
- * @returns {Object|null} Field schema
920
- */
921
- #getFieldSchema(fieldPath) {
922
- if (!this.#schema?.properties) return null
923
- return this.#walkSchemaPath(fieldPath.split('/'))
924
- }
925
-
926
- /**
927
- * Get label for a field from layout
928
- * @private
929
- * @param {string} fieldPath - Field path
930
- * @returns {string} Field label
931
- */
932
- #getFieldLabel(fieldPath) {
933
- const scope = `#/${fieldPath}`
934
- const layoutEl = this.#layout?.elements?.find((el) => el.scope === scope)
935
- return layoutElLabel(layoutEl, fieldPath)
936
- }
937
-
938
- /**
939
- * Reset form data to initial snapshot and clear validation
940
- */
941
- reset() {
942
- this.#data = deepClone(this.#initialData)
943
- this.#validation = {}
944
- }
945
-
946
- // ── Multi-Step ────────────────────────────────────────────
947
-
948
- /** True when the layout contains step elements */
210
+ // ─── steps ─────────────────────────────────────────────────────────────────
949
211
  get isMultiStep() {
950
- return (this.#layout?.elements ?? []).some((el) => el.type === 'step')
212
+ return this.steps.isMultiStep
951
213
  }
952
-
953
- /** Number of step elements in the layout */
954
214
  get totalSteps() {
955
- return (this.#layout?.elements ?? []).filter((el) => el.type === 'step').length
215
+ return this.steps.total
956
216
  }
957
-
958
- /** Zero-based index of the active step */
959
217
  get currentStep() {
960
- return this.#currentStep
218
+ return this.steps.current
961
219
  }
962
-
963
- /**
964
- * Validate the current step and advance if valid.
965
- * @returns {boolean} true if advanced, false if validation failed
966
- */
220
+ get canAdvance() {
221
+ return this.steps.canAdvance
222
+ }
223
+ /** Validate the current step, and advance only if it passes. */
967
224
  next() {
968
- if (!this.validateStep(this.#currentStep)) return false
969
- if (this.#currentStep < this.totalSteps - 1) {
970
- this.#currentStep++
971
- return true
972
- }
973
- return false
225
+ if (!this.validateStep(this.steps.current)) return false
226
+ return this.steps.advance()
974
227
  }
975
-
976
- /**
977
- * Move to the previous step without validation.
978
- * @returns {boolean} true if moved, false if already on first step
979
- */
228
+ /** Back one step, without validating. */
980
229
  prev() {
981
- if (this.#currentStep > 0) {
982
- this.#currentStep--
983
- return true
984
- }
985
- return false
230
+ return this.steps.back()
986
231
  }
987
-
988
- /**
989
- * Navigate to a previously visited step (index < currentStep).
990
- * @param {number} index - Target step index
991
- */
232
+ /** Return to a visited step. */
992
233
  goToStep(index) {
993
- if (index >= this.#currentStep) throw new Error('Cannot navigate forward to an unvisited step')
994
- this.#currentStep = index
234
+ this.steps.goTo(index)
995
235
  }
996
236
 
997
- /**
998
- * Validate all fields in a given step. Defaults to currentStep.
999
- * @param {number} [index] - Step index (defaults to currentStep)
1000
- * @returns {boolean} true if no errors
1001
- */
1002
- isStepValid(index = this.#currentStep) {
1003
- return this.validateStep(index)
237
+ // ─── lookups ───────────────────────────────────────────────────────────────
238
+ /** The underlying lookup manager, or null for a form without lookups. */
239
+ get lookupManager() {
240
+ return this.lookups.manager
1004
241
  }
1005
-
1006
- /**
1007
- * Validate all fields belonging to a step by index.
1008
- * @param {number} index - Step index
1009
- * @returns {boolean} true if no errors
1010
- */
1011
- validateStep(index) {
1012
- const stepEl = (this.#layout?.elements ?? [])[index]
1013
- if (!stepEl || stepEl.type !== 'step') return true
1014
- const stepLayout = { ...this.#layout, elements: stepEl.elements ?? [] }
1015
- const results = validateAllFields(this.#data, this.#schema, stepLayout)
1016
- this.#applyStepValidation(results, stepEl.elements)
1017
- return isAllValid(results)
242
+ /** @param {Object<string, import('./lookup.svelte.js').LookupConfig>} lookups */
243
+ setLookups(lookups) {
244
+ this.lookups.configure(lookups)
1018
245
  }
1019
-
1020
- /**
1021
- * Apply validation results for a step's fields into the validation state.
1022
- * @private
1023
- */
1024
- #applyStepValidation(results, elements) {
1025
- const paths = new SvelteSet(this.#collectStepPaths(elements))
1026
- for (const path of paths) {
1027
- this.setFieldValidation(path, results[path] ?? null)
1028
- }
246
+ /** @returns {{ options: any[], loading: boolean, error: string|null, fields: Object, disabled: boolean }|null} */
247
+ getLookupState(fieldPath) {
248
+ return this.lookups.state(toFieldPath(fieldPath))
1029
249
  }
1030
-
1031
- /**
1032
- * Collect all scoped field paths from a step's elements recursively.
1033
- * @private
1034
- * @param {Object[]} elements - Step element array
1035
- * @returns {string[]}
1036
- */
1037
- #collectStepPaths(elements) {
1038
- const paths = []
1039
- for (const el of elements ?? []) {
1040
- if (el.scope) paths.push(el.scope.replace(/^#\//, ''))
1041
- if (el.elements) paths.push(...this.#collectStepPaths(el.elements))
1042
- }
1043
- return paths
250
+ /** Disabled because a field its lookup depends on is not set yet. */
251
+ isFieldDisabled(path) {
252
+ return this.lookups.isDisabled(toFieldPath(path))
1044
253
  }
1045
-
1046
- /**
1047
- * Collect all field paths across all steps.
1048
- * @private
1049
- * @returns {Set<string>}
1050
- */
1051
- /* v8 ignore next 9 — defined for future use; not yet called by any public method */
1052
- #getAllStepPaths() {
1053
- const paths = new SvelteSet()
1054
- for (const step of this.#layout?.elements ?? []) {
1055
- if (step.type === 'step') {
1056
- for (const path of this.#collectStepPaths(step.elements)) paths.add(path)
1057
- }
1058
- }
1059
- return paths
254
+ async refreshLookup(path) {
255
+ await this.lookups.refresh(toFieldPath(path))
1060
256
  }
1061
-
1062
- /**
1063
- * Return the layout elements for the active step (or all elements for flat layouts).
1064
- * @private
1065
- * @returns {Object[]}
1066
- */
1067
- #getActiveElements() {
1068
- const elements = this.#layout?.elements ?? []
1069
- if (!elements.some((el) => el.type === 'step')) return elements
1070
- return elements[this.#currentStep]?.elements ?? []
257
+ hasLookup(fieldPath) {
258
+ return this.lookups.has(toFieldPath(fieldPath))
259
+ }
260
+ async initializeLookups() {
261
+ await this.lookups.initialize()
1071
262
  }
1072
- }
1073
-
1074
- // ── Module-level helpers (no `this`) ──────────────────────────────────────────
1075
-
1076
- /**
1077
- * Returns true when no result in the map has state === 'error'
1078
- * @private
1079
- */
1080
- function isAllValid(results) {
1081
- return Object.values(results).every((msg) => msg?.state !== 'error')
1082
- }
1083
-
1084
- /**
1085
- * Return the display label for a layout element, falling back to fieldPath
1086
- * @private
1087
- */
1088
- function layoutElLabel(layoutEl, fieldPath) {
1089
- if (layoutEl?.label) return layoutEl.label
1090
- return layoutEl?.title ?? fieldPath
1091
- }
1092
-
1093
- /**
1094
- * Apply options/loading state from lookup to finalProps
1095
- * @private
1096
- */
1097
- function applyLookupData(lookupState, finalProps) {
1098
- if (lookupState.options?.length > 0) finalProps.options = lookupState.options
1099
- if (lookupState.loading) finalProps.loading = true
1100
- }
1101
-
1102
- /**
1103
- * Apply disabled/fields state from lookup to finalProps
1104
- * @private
1105
- */
1106
- function applyLookupMeta(lookupState, finalProps) {
1107
- if (lookupState.disabled) finalProps.disabled = true
1108
- if (lookupState.fields && !finalProps.fields) finalProps.fields = lookupState.fields
1109
- }
1110
-
1111
- /**
1112
- * Merge all lookup properties into finalProps
1113
- * @private
1114
- */
1115
- function applyLookupProps(lookupState, finalProps) {
1116
- applyLookupData(lookupState, finalProps)
1117
- applyLookupMeta(lookupState, finalProps)
1118
- }
1119
-
1120
- /**
1121
- * Add path to dirty set if current and initial values differ
1122
- * @private
1123
- * @param {{ current: any, initial: any, prefix: string, key: string, dirty: Set<string> }} ctx
1124
- */
1125
- function collectIfDirty({ current, initial, prefix, key, dirty }) {
1126
- const path = prefix ? `${prefix}/${key}` : key
1127
- if (!deepEqual(current?.[key], initial?.[key])) dirty.add(path)
1128
263
  }