@json-layout/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@json-layout/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Compilation and state management utilities for JSON Layout.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "files": [
21
21
  "src",
22
- "types"
22
+ "types",
23
+ "LICENSE"
23
24
  ],
24
25
  "scripts": {
25
26
  "test:single": "node --no-experimental-fetch --test --test-only test/*.spec.js",
@@ -46,7 +47,7 @@
46
47
  },
47
48
  "homepage": "https://github.com/json-layout/json-layout#readme",
48
49
  "dependencies": {
49
- "@json-layout/vocabulary": "^0.1.0",
50
+ "@json-layout/vocabulary": "^0.3.0",
50
51
  "@types/markdown-it": "^13.0.1",
51
52
  "ajv": "^8.12.0",
52
53
  "ajv-errors": "^3.0.0",
@@ -27,7 +27,7 @@ const Ajv = /** @type {typeof ajvModule.default} */ (ajvModule)
27
27
  // @ts-ignore
28
28
  const ajvLocalize = /** @type {typeof ajvLocalizeModule.default} */ (ajvLocalizeModule)
29
29
 
30
- const expressionsParams = ['data', 'options', 'display']
30
+ const expressionsParams = ['data', 'options', 'context', 'display']
31
31
 
32
32
  const clone = rfdc()
33
33
  // const exprEvalParser = new ExprEvalParser()
@@ -87,8 +87,19 @@ export function compile (_schema, partialOptions = {}) {
87
87
  const normalizedLayouts = {}
88
88
  /** @type {import('@json-layout/vocabulary').Expression[]} */
89
89
  const expressionsDefinitions = []
90
+ /** @type {Record<string, string[]>} */
91
+ const validationErrors = {}
90
92
 
91
- const skeletonTree = makeSkeletonTree(schema, options, validatePointers, normalizedLayouts, expressionsDefinitions, `${schema.$id}#`, 'main')
93
+ const skeletonTree = makeSkeletonTree(
94
+ schema,
95
+ options,
96
+ validatePointers,
97
+ validationErrors,
98
+ normalizedLayouts,
99
+ expressionsDefinitions,
100
+ `${schema.$id}#`,
101
+ 'main'
102
+ )
92
103
 
93
104
  options.ajv.addSchema(schema)
94
105
 
@@ -126,6 +137,7 @@ export function compile (_schema, partialOptions = {}) {
126
137
  schema,
127
138
  skeletonTree,
128
139
  validates,
140
+ validationErrors,
129
141
  normalizedLayouts,
130
142
  expressions,
131
143
  locale: options.locale,
@@ -26,7 +26,7 @@ export function serialize (compiledLayout) {
26
26
 
27
27
  code = code.replace('"use strict";', '')
28
28
 
29
- // some internal imports to ajv are not translated to asm, we do it here
29
+ // some internal imports to ajv are not translated to esm, we do it here
30
30
  // cf https://github.com/ajv-validator/ajv-formats/pull/73
31
31
  if (code.includes('require("ajv-formats/dist/formats")')) {
32
32
  code = 'import { fullFormats } from "ajv-formats/dist/formats.js";\n' + code
@@ -37,7 +37,7 @@ export function serialize (compiledLayout) {
37
37
  code = code.replace(/require\("ajv\/dist\/runtime\/ucs2length"\)/g, 'ucs2length')
38
38
  }
39
39
 
40
- // importe only the current locale from ajv-i18n
40
+ // import only the current locale from ajv-i18n
41
41
  code = `import localizeErrors from "ajv-i18n/localize/${compiledLayout.locale}/index.js";
42
42
  export const exportLocalizeErrors = localizeErrors;\n` + code
43
43
 
@@ -55,6 +55,7 @@ export const exportLocalizeErrors = localizeErrors;\n` + code
55
55
  skeletonTree: compiledLayout.skeletonTree,
56
56
  normalizedLayouts: compiledLayout.normalizedLayouts,
57
57
  validates: {},
58
+ validationErrors: compiledLayout.validationErrors,
58
59
  expressions: expressionsNodes,
59
60
  locale: compiledLayout.locale,
60
61
  messages: compiledLayout.messages,
@@ -1,5 +1,5 @@
1
1
  // import Debug from 'debug'
2
- import { normalizeLayoutFragment, isSwitchStruct, isGetItemsExpression, isSelectLayout, isGetItemsFetch } from '@json-layout/vocabulary'
2
+ import { normalizeLayoutFragment, isSwitchStruct, isGetItemsExpression, isGetItemsFetch, isItemsLayout } from '@json-layout/vocabulary'
3
3
  import { makeSkeletonTree } from './skeleton-tree.js'
4
4
 
5
5
  /**
@@ -20,6 +20,7 @@ const pushExpression = (expressions, expression) => {
20
20
  * @param {any} schema
21
21
  * @param {import('./index.js').CompileOptions} options
22
22
  * @param {string[]} validates
23
+ * @param {Record<string, string[]>} validationErrors
23
24
  * @param {Record<string, import('@json-layout/vocabulary').NormalizedLayout>} normalizedLayouts
24
25
  * @param {import('@json-layout/vocabulary').Expression[]} expressions
25
26
  * @param {string | number} key
@@ -32,6 +33,7 @@ export function makeSkeletonNode (
32
33
  schema,
33
34
  options,
34
35
  validates,
36
+ validationErrors,
35
37
  normalizedLayouts,
36
38
  expressions,
37
39
  key,
@@ -44,36 +46,46 @@ export function makeSkeletonNode (
44
46
 
45
47
  // improve on ajv error messages based on ajv-errors (https://ajv.js.org/packages/ajv-errors.html)
46
48
  schema.errorMessage = schema.errorMessage ?? {}
47
- /** @type {import('@json-layout/vocabulary').NormalizedLayout} */
48
- const normalizedLayout = normalizedLayouts[pointer] ?? normalizeLayoutFragment(/** @type {import('@json-layout/vocabulary').SchemaFragment} */(schema), pointer, options.markdown)
49
- normalizedLayouts[pointer] = normalizedLayout
49
+ if (!normalizedLayouts[pointer]) {
50
+ const normalizationResult = normalizeLayoutFragment(/** @type {import('@json-layout/vocabulary').SchemaFragment} */(schema), pointer, options.markdown)
51
+ normalizedLayouts[pointer] = normalizationResult.layout
52
+ if (normalizationResult.errors.length) {
53
+ validationErrors[pointer.replace('_jl#', '/')] = normalizationResult.errors
54
+ }
55
+ }
56
+ const normalizedLayout = normalizedLayouts[pointer]
57
+
58
+ let defaultData
59
+ if ('default' in schema) defaultData = schema.default
60
+ else if (required) {
61
+ if (schema.type === 'object') defaultData = {}
62
+ if (schema.type === 'array') defaultData = []
63
+ }
50
64
 
51
65
  const compObjects = isSwitchStruct(normalizedLayout) ? normalizedLayout.switch : [normalizedLayout]
52
66
  for (const compObject of compObjects) {
53
67
  if (schema.description && !compObject.help) compObject.help = schema.description
54
68
  if (compObject.if) pushExpression(expressions, compObject.if)
55
- if (isSelectLayout(compObject) && compObject.getItems) {
69
+
70
+ if ('const' in schema) compObject.constData = { type: 'js-eval', expr: JSON.stringify(schema.const) }
71
+ if (compObject.constData) pushExpression(expressions, compObject.constData)
72
+
73
+ if (defaultData && !compObject.defaultData) compObject.defaultData = { type: 'js-eval', expr: JSON.stringify(defaultData) }
74
+ if (compObject.defaultData) pushExpression(expressions, compObject.defaultData)
75
+
76
+ if (isItemsLayout(compObject) && compObject.getItems) {
56
77
  if (isGetItemsExpression(compObject.getItems)) pushExpression(expressions, compObject.getItems)
57
78
  if (isGetItemsFetch(compObject.getItems)) pushExpression(expressions, compObject.getItems.url)
58
79
  if (compObject.getItems.itemTitle) pushExpression(expressions, compObject.getItems.itemTitle)
59
80
  if (compObject.getItems.itemKey) pushExpression(expressions, compObject.getItems.itemKey)
60
81
  if (compObject.getItems.itemValue) pushExpression(expressions, compObject.getItems.itemValue)
82
+ if (compObject.getItems.itemIcon) pushExpression(expressions, compObject.getItems.itemIcon)
61
83
  if (compObject.getItems.itemsResults) pushExpression(expressions, compObject.getItems.itemsResults)
62
84
  }
63
85
  }
64
86
 
65
- let defaultData
66
- if (schema.const) defaultData = schema.const
67
- else if (schema.default) defaultData = schema.default
68
- if (required) {
69
- if (schema.type === 'object') defaultData = {} // TODO: this is only true if property is required ?
70
- if (schema.type === 'array') defaultData = []
71
- if (schema.type === 'string' && !schema.format) defaultData = ''
72
- }
73
-
74
87
  /** @type {import('./types.js').SkeletonNode} */
75
- const node = { key: key ?? '', pointer, parentPointer, defaultData }
76
- if (schema.const) node.const = schema.const
88
+ const node = { key: key ?? '', pointer, parentPointer }
77
89
  if (schema.type === 'object') {
78
90
  if (schema.properties) {
79
91
  node.children = node.children ?? []
@@ -82,6 +94,7 @@ export function makeSkeletonNode (
82
94
  schema.properties[propertyKey],
83
95
  options,
84
96
  validates,
97
+ validationErrors,
85
98
  normalizedLayouts,
86
99
  expressions,
87
100
  propertyKey,
@@ -102,6 +115,7 @@ export function makeSkeletonNode (
102
115
  schema.allOf[i],
103
116
  options,
104
117
  validates,
118
+ validationErrors,
105
119
  normalizedLayouts,
106
120
  expressions,
107
121
  `$allOf-${i}`,
@@ -113,14 +127,29 @@ export function makeSkeletonNode (
113
127
  }
114
128
  if (schema.oneOf) {
115
129
  const oneOfPointer = `${pointer}/oneOf`
116
- normalizedLayouts[oneOfPointer] = normalizedLayouts[oneOfPointer] ?? normalizeLayoutFragment(schema, oneOfPointer, options.markdown, 'oneOf')
130
+ if (!normalizedLayouts[oneOfPointer]) {
131
+ const normalizationResult = normalizeLayoutFragment(schema, oneOfPointer, options.markdown, 'oneOf')
132
+ normalizedLayouts[oneOfPointer] = normalizationResult.layout
133
+ if (normalizationResult.errors.length) {
134
+ validationErrors[oneOfPointer.replace('_jl#', '/')] = normalizationResult.errors
135
+ }
136
+ }
117
137
  /** @type {import('./types.js').SkeletonTree[]} */
118
138
  const childrenTrees = []
119
139
  for (let i = 0; i < schema.oneOf.length; i++) {
120
140
  if (!schema.oneOf[i].type) schema.oneOf[i].type = schema.type
121
141
  const title = schema.oneOf[i].title ?? `option ${i}`
122
142
  delete schema.oneOf[i].title
123
- childrenTrees.push(makeSkeletonTree(schema.oneOf[i], options, validates, normalizedLayouts, expressions, `${oneOfPointer}/${i}`, title))
143
+ childrenTrees.push(makeSkeletonTree(
144
+ schema.oneOf[i],
145
+ options,
146
+ validates,
147
+ validationErrors,
148
+ normalizedLayouts,
149
+ expressions,
150
+ `${oneOfPointer}/${i}`,
151
+ title
152
+ ))
124
153
  }
125
154
  node.children = node.children ?? []
126
155
  node.children.push({ key: '$oneOf', pointer: `${pointer}/oneOf`, parentPointer: pointer, childrenTrees })
@@ -136,6 +165,7 @@ export function makeSkeletonNode (
136
165
  itemSchema,
137
166
  options,
138
167
  validates,
168
+ validationErrors,
139
169
  normalizedLayouts,
140
170
  expressions,
141
171
  i,
@@ -150,6 +180,7 @@ export function makeSkeletonNode (
150
180
  schema.items,
151
181
  options,
152
182
  validates,
183
+ validationErrors,
153
184
  normalizedLayouts,
154
185
  expressions,
155
186
  `${pointer}/items`,
@@ -7,6 +7,7 @@ import { makeSkeletonNode } from './skeleton-node.js'
7
7
  * @param {any} schema
8
8
  * @param {import('./index.js').CompileOptions} options
9
9
  * @param {string[]} validates
10
+ * @param {Record<string, string[]>} validationErrors
10
11
  * @param {Record<string, import('@json-layout/vocabulary').NormalizedLayout>} normalizedLayouts
11
12
  * @param {import('@json-layout/vocabulary').Expression[]} expressions
12
13
  * @param {string} pointer
@@ -17,12 +18,13 @@ export function makeSkeletonTree (
17
18
  schema,
18
19
  options,
19
20
  validates,
21
+ validationErrors,
20
22
  normalizedLayouts,
21
23
  expressions,
22
24
  pointer,
23
25
  title
24
26
  ) {
25
- const root = makeSkeletonNode(schema, options, validates, normalizedLayouts, expressions, '', pointer, null, true)
27
+ const root = makeSkeletonNode(schema, options, validates, validationErrors, normalizedLayouts, expressions, '', pointer, null, true)
26
28
  validates.push(pointer)
27
29
  return { title, root }
28
30
  }
@@ -1,11 +1,11 @@
1
1
  import type ajvModule from 'ajv'
2
2
  import type MarkdownIt from 'markdown-it'
3
3
  import { type NormalizedLayout, type StateNodeOptions } from '@json-layout/vocabulary'
4
- import { type ValidateFunction, type SchemaObject } from 'ajv'
4
+ import { type ValidateFunction, type SchemaObject, type ErrorObject } from 'ajv'
5
5
  import { type Display } from '../state/utils/display.js'
6
6
  import { type LocaleMessages } from '../i18n/types.js'
7
7
 
8
- export type CompiledExpression = (data: any, options: StateNodeOptions, display: Display) => any
8
+ export type CompiledExpression = (data: any, options: StateNodeOptions, context: object, display: Display) => any
9
9
 
10
10
  export interface CompileOptions {
11
11
  ajv: ajvModule.default
@@ -23,6 +23,7 @@ export interface CompiledLayout {
23
23
  schema?: SchemaObject
24
24
  skeletonTree: SkeletonTree
25
25
  validates: Record<string, ValidateFunction>
26
+ validationErrors: Record<string, string[]>
26
27
  normalizedLayouts: Record<string, NormalizedLayout>
27
28
  expressions: CompiledExpression[]
28
29
  locale: string
@@ -43,8 +44,6 @@ export interface SkeletonNode {
43
44
  key: string | number
44
45
  pointer: string
45
46
  parentPointer: string | null
46
- defaultData?: unknown
47
- const?: unknown
48
47
  children?: SkeletonNode[] // optional children in the case of arrays and object nodes
49
48
  childrenTrees?: SkeletonTree[] // other trees that can be instantiated with separate validation (for example in the case of new array items of oneOfs, etc)
50
49
  }
package/src/i18n/en.js CHANGED
@@ -6,5 +6,27 @@ export default {
6
6
  delete: 'Delete',
7
7
  edit: 'Edit',
8
8
  duplicate: 'Duplicate',
9
- sort: 'Sort'
9
+ sort: 'Sort',
10
+ up: 'Move up',
11
+ down: 'Move down',
12
+ showHelp: 'Show a help message',
13
+ mdeLink1: '[Link title',
14
+ mdeLink2: '](link url)',
15
+ mdeImg1: '![](',
16
+ mdeImg2: 'image url)',
17
+ mdeTable1: '',
18
+ mdeTable2: '\n\n| Column 1 | Column 2 | ColoColumnnne 3 |\n| -------- | -------- | -------- |\n| Text | Text | Text |\n\n',
19
+ bold: 'Bold',
20
+ italic: 'Italic',
21
+ heading: 'Title',
22
+ quote: 'Quote',
23
+ unorderedList: 'Unordered list',
24
+ orderedList: 'Ordered list',
25
+ createLink: 'Create a link',
26
+ insertImage: 'Insert an image',
27
+ createTable: 'Create a table',
28
+ preview: 'Aperçu du rendu',
29
+ mdeGuide: 'Documentation de la syntaxe',
30
+ undo: 'Undo',
31
+ redo: 'Redo'
10
32
  }
package/src/i18n/fr.js CHANGED
@@ -6,5 +6,27 @@ export default {
6
6
  delete: 'Supprimer',
7
7
  edit: 'Éditer',
8
8
  duplicate: 'Dupliquer',
9
- sort: 'Trier'
9
+ sort: 'Trier',
10
+ up: 'Décaler vers le haut',
11
+ down: 'Décaler vers le bas',
12
+ showHelp: 'Afficher un message d\'aide',
13
+ mdeLink1: '[titre du lien',
14
+ mdeLink2: '](adresse du lien)',
15
+ mdeImg1: '![](',
16
+ mdeImg2: 'adresse de l\'image)',
17
+ mdeTable1: '',
18
+ mdeTable2: '\n\n| Colonne 1 | Colonne 2 | Colonne 3 |\n| -------- | -------- | -------- |\n| Texte | Texte | Texte |\n\n',
19
+ bold: 'Gras',
20
+ italic: 'Italique',
21
+ heading: 'Titre',
22
+ quote: 'Citation',
23
+ unorderedList: 'Liste à puce',
24
+ orderedList: 'Liste numérotée',
25
+ createLink: 'Créer un lien',
26
+ insertImage: 'Insérer une image',
27
+ createTable: 'Créer un tableau',
28
+ preview: 'Preview',
29
+ mdeGuide: 'Syntax documentation',
30
+ undo: 'Défaire',
31
+ redo: 'Refaire'
10
32
  }
package/src/i18n/types.ts CHANGED
@@ -9,6 +9,28 @@ export interface StateOptionsMessages {
9
9
  edit: string
10
10
  duplicate: string
11
11
  sort: string
12
+ up: string
13
+ down: string
14
+ showHelp: string
15
+ mdeLink1: string
16
+ mdeLink2: string
17
+ mdeImg1: string
18
+ mdeImg2: string
19
+ mdeTable1: string
20
+ mdeTable2: string
21
+ bold: string
22
+ italic: string
23
+ heading: string
24
+ quote: string
25
+ unorderedList: string
26
+ orderedList: string
27
+ createLink: string
28
+ insertImage: string
29
+ createTable: string
30
+ preview: string
31
+ mdeGuide: string
32
+ undo: string
33
+ redo: string
12
34
  }
13
35
 
14
36
  export type LocaleMessages = CompileOptionsMessages & StateOptionsMessages
@@ -4,7 +4,7 @@ import debug from 'debug'
4
4
  import { evalExpression, producePatchedData } from './state-node.js'
5
5
  import { createStateTree } from './state-tree.js'
6
6
  import { Display } from './utils/display.js'
7
- import { isGetItemsExpression, isGetItemsFetch } from '@json-layout/vocabulary'
7
+ import { isGetItemsExpression, isGetItemsFetch, isItemsLayout } from '@json-layout/vocabulary'
8
8
 
9
9
  export { Display } from './utils/display.js'
10
10
 
@@ -20,6 +20,8 @@ export { Display } from './utils/display.js'
20
20
  * @typedef {import('./types.js').SliderNode} SliderNode
21
21
  * @typedef {import('./types.js').SectionNode} SectionNode
22
22
  * @typedef {import('./types.js').SelectNode} SelectNode
23
+ * @typedef {import('./types.js').AutocompleteNode} AutocompleteNode
24
+ * @typedef {import('./types.js').ComboboxNode} ComboboxNode
23
25
  * @typedef {import('./types.js').CheckboxNode} CheckboxNode
24
26
  * @typedef {import('./types.js').SwitchNode} SwitchNode
25
27
  * @typedef {import('./types.js').ColorPickerNode} ColorPickerNode
@@ -29,6 +31,7 @@ export { Display } from './utils/display.js'
29
31
  * @typedef {import('./types.js').ExpansionPanelsNode} ExpansionPanelsNode
30
32
  * @typedef {import('./types.js').TabsNode} TabsNode
31
33
  * @typedef {import('./types.js').VerticalTabsNode} VerticalTabsNode
34
+ * @typedef {import('./types.js').StepperNode} StepperNode
32
35
  * @typedef {import('./types.js').OneOfSelectNode} OneOfSelectNode
33
36
  * @typedef {import('./types.js').ListNode} ListNode
34
37
  */
@@ -36,8 +39,8 @@ export { Display } from './utils/display.js'
36
39
  /** @type {(node: StateNode | undefined) => node is SectionNode} */
37
40
  export const isSection = (node) => !!node && node.layout.comp === 'section'
38
41
 
39
- /** @type {(node: StateNode | undefined) => node is SelectNode} */
40
- export const isSelect = (node) => !!node && node.layout.comp === 'select'
42
+ /** @type {(node: StateNode | undefined) => node is SelectNode | ComboboxNode | AutocompleteNode} */
43
+ export const isItemsNode = (node) => !!node && isItemsLayout(node.layout)
41
44
 
42
45
  const logDataBinding = debug('jl:data-binding')
43
46
 
@@ -61,6 +64,8 @@ function fillOptions (partialOptions, compiledLayout) {
61
64
  titleDepth: 2,
62
65
  validateOn: 'input',
63
66
  initialValidation: 'withData',
67
+ defaultOn: 'empty',
68
+ autofocus: false,
64
69
  ...partialOptions,
65
70
  messages
66
71
  }
@@ -166,22 +171,36 @@ export class StatefulLayout {
166
171
  // @ts-ignore
167
172
  _lastCreateStateTreeContext
168
173
 
174
+ /**
175
+ * @private
176
+ * @type {string | null}
177
+ */
178
+ _autofocusTarget
179
+ /**
180
+ * @private
181
+ * @type {string | null}
182
+ */
183
+ _previousAutofocusTarget
184
+
169
185
  /**
170
186
  * @param {import("../index.js").CompiledLayout} compiledLayout
171
187
  * @param {import("../index.js").SkeletonTree} skeletonTree
172
188
  * @param {Partial<StatefulLayoutOptions>} options
173
- * @param {unknown} data
189
+ * @param {unknown} [data]
174
190
  */
175
- constructor (compiledLayout, skeletonTree, options, data = {}) {
191
+ constructor (compiledLayout, skeletonTree, options, data) {
176
192
  this._compiledLayout = compiledLayout
177
193
  this.skeletonTree = skeletonTree
178
194
  /** @type {import('mitt').Emitter<StatefulLayoutEvents>} */
179
195
  this.events = mitt()
180
196
  this.prepareOptions(options)
197
+ this._autofocusTarget = this.options.autofocus ? '' : null
198
+ this._previousAutofocusTarget = null
181
199
  this._data = data
182
200
  this.initValidationState()
183
201
  this.activeItems = {}
184
202
  this.updateState()
203
+ this.handleAutofocus()
185
204
  }
186
205
 
187
206
  /**
@@ -210,10 +229,11 @@ export class StatefulLayout {
210
229
  */
211
230
  updateState () {
212
231
  this.createStateTree()
213
- if (this._data !== this._stateTree.root.data) {
232
+ if (this._data !== this._stateTree.root.data || this._autofocusTarget !== this._lastCreateStateTreeContext.autofocusTarget) {
214
233
  logDataBinding('hydrating state tree changed the data, do it again', this._data, this._stateTree.root.data)
215
234
  // this is necessary because a first hydration can add default values and change validity, etc
216
235
  this._data = this._stateTree.root.data
236
+ this._autofocusTarget = this._lastCreateStateTreeContext.autofocusTarget
217
237
  this.createStateTree()
218
238
  }
219
239
  logDataBinding('emit update event', this._data, this._stateTree)
@@ -225,7 +245,12 @@ export class StatefulLayout {
225
245
  */
226
246
  createStateTree () {
227
247
  /** @type {CreateStateTreeContext} */
228
- const createStateTreeContext = { nodes: [], activeItems: this.activeItems }
248
+ const createStateTreeContext = {
249
+ nodes: [],
250
+ activeItems: this.activeItems,
251
+ autofocusTarget: this._autofocusTarget,
252
+ initial: !this._lastCreateStateTreeContext
253
+ }
229
254
  this._stateTree = createStateTree(
230
255
  createStateTreeContext,
231
256
  this._options,
@@ -238,7 +263,11 @@ export class StatefulLayout {
238
263
  )
239
264
  this._lastCreateStateTreeContext = createStateTreeContext
240
265
  if (!this.validationState.initialized) {
241
- this.validationState = { initialized: true, validatedChildren: createStateTreeContext.nodes.filter(n => n.validated).map(n => n.fullKey) }
266
+ this._validationState = {
267
+ initialized: true,
268
+ validatedForm: this._validationState.validatedForm,
269
+ validatedChildren: createStateTreeContext.nodes.filter(n => n.validated).map(n => n.fullKey)
270
+ }
242
271
  }
243
272
  }
244
273
 
@@ -258,6 +287,13 @@ export class StatefulLayout {
258
287
  return this.stateTree.valid
259
288
  }
260
289
 
290
+ /**
291
+ * @returns {string[]}
292
+ */
293
+ get errors () {
294
+ return this._lastCreateStateTreeContext.nodes.filter(n => !!n.error).map(n => /** @type {string} */(n.error))
295
+ }
296
+
261
297
  /**
262
298
  * @returns {boolean}
263
299
  */
@@ -277,6 +313,7 @@ export class StatefulLayout {
277
313
  }
278
314
  if (activateKey !== undefined) {
279
315
  this.activeItems[node.fullKey] = activateKey
316
+ this._autofocusTarget = node.fullKey + '/' + activateKey
280
317
  }
281
318
  if (node.parentFullKey === null) {
282
319
  this.data = data
@@ -287,6 +324,10 @@ export class StatefulLayout {
287
324
  if (!parentNode) throw new Error(`parent with key "${node.parentFullKey}" not found`)
288
325
  const newParentValue = producePatchedData(parentNode.data ?? {}, node, data)
289
326
  this.input(parentNode, newParentValue)
327
+
328
+ if (activateKey !== undefined) {
329
+ this.handleAutofocus()
330
+ }
290
331
  }
291
332
 
292
333
  /**
@@ -304,11 +345,26 @@ export class StatefulLayout {
304
345
 
305
346
  /**
306
347
  * @param {StateNode} node
307
- * @returns {Promise<import('@json-layout/vocabulary').SelectItems>}
308
348
  */
309
- async getSelectItems (node) {
310
- if (!isSelect(node)) throw new Error('node is not a select component')
311
- if (node.layout.items) return node.layout.items
349
+ validateNodeRecurse (node) {
350
+ this.validationState = { validatedChildren: this.validationState.validatedChildren.concat([node.fullKey]) }
351
+ if (node.children) {
352
+ for (const child of node.children) {
353
+ this.validateNodeRecurse(child)
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * @private
360
+ * @param {StateNode} node
361
+ * @param {string} q
362
+ * @returns {Promise<[import('@json-layout/vocabulary').SelectItems, boolean]>}
363
+ */
364
+ async getSourceItems (node, q = '') {
365
+ if (!isItemsNode(node)) throw new Error('node is not a component with an items list')
366
+
367
+ if (node.layout.items) return [node.layout.items, false]
312
368
 
313
369
  /** @type {(expression: import('@json-layout/vocabulary').Expression, data: any) => any} */
314
370
  const evalSelectExpression = (expression, data) => {
@@ -316,12 +372,24 @@ export class StatefulLayout {
316
372
  }
317
373
 
318
374
  let rawItems
375
+ let appliedQ = false
319
376
  if (node.layout.getItems && isGetItemsExpression(node.layout.getItems)) {
320
377
  rawItems = evalSelectExpression(node.layout.getItems, null)
321
378
  if (!Array.isArray(rawItems)) throw new Error('getItems expression didn\'t return an array')
322
379
  }
323
380
  if (node.layout.getItems && isGetItemsFetch(node.layout.getItems)) {
324
- const url = evalSelectExpression(node.layout.getItems.url, null)
381
+ const url = new URL(evalSelectExpression(node.layout.getItems.url, null))
382
+ let qSearchParam = node.layout.getItems.qSearchParam
383
+ if (!qSearchParam) {
384
+ for (const searchParam of url.searchParams.entries()) {
385
+ if (searchParam[1] === '{q}') qSearchParam = searchParam[0]
386
+ }
387
+ }
388
+ if (qSearchParam) {
389
+ appliedQ = true
390
+ if (q) url.searchParams.set(qSearchParam, q)
391
+ else url.searchParams.delete(qSearchParam)
392
+ }
325
393
  rawItems = await (await fetch(url)).json()
326
394
  }
327
395
 
@@ -329,28 +397,40 @@ export class StatefulLayout {
329
397
  if (node.layout.getItems?.itemsResults) {
330
398
  rawItems = evalSelectExpression(node.layout.getItems.itemsResults, rawItems)
331
399
  }
332
- return rawItems.map((/** @type {any} */ rawItem) => {
400
+ /** @type {import('@json-layout/vocabulary').SelectItems} */
401
+ const items = rawItems.map((/** @type {any} */ rawItem) => {
402
+ /** @type {Partial<import('@json-layout/vocabulary').SelectItem>} */
403
+ const item = {}
333
404
  if (typeof rawItem === 'object') {
334
- /** @type {Partial<import('@json-layout/vocabulary').SelectItem>} */
335
- const item = {}
336
405
  item.value = node.layout.getItems?.itemValue ? evalSelectExpression(node.layout.getItems.itemValue, rawItem) : (node.layout.getItems?.returnObjects ? rawItem : rawItem.value)
337
406
  item.key = node.layout.getItems?.itemKey ? evalSelectExpression(node.layout.getItems.itemKey, rawItem) : rawItem.key
338
407
  item.title = node.layout.getItems?.itemTitle ? evalSelectExpression(node.layout.getItems.itemTitle, rawItem) : rawItem.title
339
408
  item.value = item.value ?? item.key
340
409
  item.key = item.key ?? item.value + ''
341
410
  item.title = item.title ?? item.key
342
- return item
411
+ if (!item.icon && rawItem.icon) item.icon = rawItem.icon
343
412
  } else {
344
- /** @type {Partial<import('@json-layout/vocabulary').SelectItem>} */
345
- const item = {}
346
413
  item.value = node.layout.getItems?.itemValue ? evalSelectExpression(node.layout.getItems.itemValue, rawItem) : rawItem
347
414
  item.key = node.layout.getItems?.itemKey ? evalSelectExpression(node.layout.getItems.itemKey, rawItem) : item.value
348
415
  item.title = node.layout.getItems?.itemTitle ? evalSelectExpression(node.layout.getItems.itemTitle, rawItem) : item.value
349
- return item
350
416
  }
417
+ if (node.layout.getItems?.itemIcon) item.icon = evalSelectExpression(node.layout.getItems?.itemIcon, rawItem)
418
+ return item
351
419
  })
420
+ return [items, appliedQ]
352
421
  }
353
- throw new Error('node is missing items or getItems parameters')
422
+ throw new Error(`node ${node.fullKey} is missing items or getItems parameters`)
423
+ }
424
+
425
+ /**
426
+ * @param {StateNode} node
427
+ * @param {string} q
428
+ * @returns {Promise<import('@json-layout/vocabulary').SelectItems>}
429
+ */
430
+ async getItems (node, q = '') {
431
+ const [sourceItems, appliedQ] = await this.getSourceItems(node, q)
432
+ if (q && !appliedQ) return sourceItems.filter(item => item.title.toLowerCase().includes(q.toLowerCase()))
433
+ return sourceItems
354
434
  }
355
435
 
356
436
  /**
@@ -364,11 +444,13 @@ export class StatefulLayout {
364
444
  */
365
445
  activateItem (node, key) {
366
446
  this.activeItems[node.fullKey] = key
447
+ this._autofocusTarget = node.fullKey + '/' + key
367
448
  if (node.key === '$oneOf') {
368
- this.input(node, node.skeleton.childrenTrees?.[key].root.defaultData)
449
+ this.input(node, undefined)
369
450
  } else {
370
451
  this.updateState()
371
452
  }
453
+ this.handleAutofocus()
372
454
  }
373
455
 
374
456
  /**
@@ -378,4 +460,15 @@ export class StatefulLayout {
378
460
  delete this.activeItems[node.fullKey]
379
461
  this.updateState()
380
462
  }
463
+
464
+ handleAutofocus () {
465
+ const autofocusTarget = this._autofocusTarget
466
+ if (autofocusTarget !== null && this._autofocusTarget !== this._previousAutofocusTarget) {
467
+ this._previousAutofocusTarget = autofocusTarget
468
+ setTimeout(() => {
469
+ logDataBinding('emit autofocus event', autofocusTarget)
470
+ this.events.emit('autofocus', autofocusTarget)
471
+ })
472
+ }
473
+ }
381
474
  }