@typeonce/effect-machine-devtools 0.23.0 → 0.24.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.
Files changed (50) hide show
  1. package/README.md +10 -4
  2. package/dist/DevServer.d.ts +2 -1
  3. package/dist/DevServer.d.ts.map +1 -1
  4. package/dist/DevServer.js.map +1 -1
  5. package/dist/DevToolsProtocol.d.ts +731 -17
  6. package/dist/DevToolsProtocol.d.ts.map +1 -1
  7. package/dist/DevToolsProtocol.js +191 -1
  8. package/dist/DevToolsProtocol.js.map +1 -1
  9. package/dist/MachineDocument.d.ts +78 -2
  10. package/dist/MachineDocument.d.ts.map +1 -1
  11. package/dist/MachineDocument.js +33 -1
  12. package/dist/MachineDocument.js.map +1 -1
  13. package/dist/MachineRegistry.d.ts +36 -6
  14. package/dist/MachineRegistry.d.ts.map +1 -1
  15. package/dist/ProjectInspector.d.ts +2 -0
  16. package/dist/ProjectInspector.d.ts.map +1 -1
  17. package/dist/ProjectInspector.js.map +1 -1
  18. package/dist/client/assets/index-B49Tjawc.js +14 -0
  19. package/dist/client/assets/index-BKH0cOG2.css +1 -0
  20. package/dist/client/index.html +2 -2
  21. package/dist/internal/devServer.d.ts +2 -1
  22. package/dist/internal/devServer.d.ts.map +1 -1
  23. package/dist/internal/devServer.js +68 -7
  24. package/dist/internal/devServer.js.map +1 -1
  25. package/dist/internal/evaluationWorker.d.ts.map +1 -1
  26. package/dist/internal/evaluationWorker.js +279 -4
  27. package/dist/internal/evaluationWorker.js.map +1 -1
  28. package/dist/internal/machineDocument.d.ts.map +1 -1
  29. package/dist/internal/machineDocument.js +63 -2
  30. package/dist/internal/machineDocument.js.map +1 -1
  31. package/dist/internal/projectInspector.d.ts.map +1 -1
  32. package/dist/internal/projectInspector.js +26 -10
  33. package/dist/internal/projectInspector.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/DevServer.ts +6 -2
  36. package/src/DevToolsProtocol.ts +286 -1
  37. package/src/MachineDocument.ts +54 -1
  38. package/src/ProjectInspector.ts +5 -0
  39. package/src/internal/browser/input-form.ts +669 -0
  40. package/src/internal/browser/planner-example.ts +143 -0
  41. package/src/internal/browser/simulation-client.ts +20 -0
  42. package/src/internal/browser/styles.css +323 -3
  43. package/src/internal/browser/visualizer-app.ts +395 -34
  44. package/src/internal/browser/visualizer.ts +1 -1
  45. package/src/internal/devServer.ts +91 -8
  46. package/src/internal/evaluationWorker.ts +390 -8
  47. package/src/internal/machineDocument.ts +75 -2
  48. package/src/internal/projectInspector.ts +58 -15
  49. package/dist/client/assets/index-BGOhE3Ng.css +0 -1
  50. package/dist/client/assets/index-DiqGhsGR.js +0 -14
@@ -0,0 +1,669 @@
1
+ import type { InputIssue } from "../../DevToolsProtocol.js"
2
+ import type { InputSchema } from "../../MachineDocument.js"
3
+
4
+ type JsonPrimitive = string | number | boolean | null
5
+
6
+ export type InputField =
7
+ | {
8
+ readonly _tag: "String"
9
+ readonly title: string | undefined
10
+ readonly description: string | undefined
11
+ readonly defaultValue: string | undefined
12
+ readonly format: string | undefined
13
+ readonly minLength: number | undefined
14
+ readonly maxLength: number | undefined
15
+ readonly pattern: string | undefined
16
+ }
17
+ | {
18
+ readonly _tag: "Number"
19
+ readonly title: string | undefined
20
+ readonly description: string | undefined
21
+ readonly defaultValue: number | undefined
22
+ readonly integer: boolean
23
+ readonly minimum: number | undefined
24
+ readonly maximum: number | undefined
25
+ }
26
+ | {
27
+ readonly _tag: "Boolean"
28
+ readonly title: string | undefined
29
+ readonly description: string | undefined
30
+ readonly defaultValue: boolean | undefined
31
+ }
32
+ | {
33
+ readonly _tag: "Enum"
34
+ readonly title: string | undefined
35
+ readonly description: string | undefined
36
+ readonly values: ReadonlyArray<JsonPrimitive>
37
+ readonly defaultValue: JsonPrimitive | undefined
38
+ }
39
+ | {
40
+ readonly _tag: "Literal"
41
+ readonly title: string | undefined
42
+ readonly description: string | undefined
43
+ readonly value: JsonPrimitive
44
+ }
45
+ | {
46
+ readonly _tag: "Object"
47
+ readonly title: string | undefined
48
+ readonly description: string | undefined
49
+ readonly fields: ReadonlyArray<{
50
+ readonly key: string
51
+ readonly required: boolean
52
+ readonly field: InputField
53
+ }>
54
+ }
55
+ | {
56
+ readonly _tag: "Array"
57
+ readonly title: string | undefined
58
+ readonly description: string | undefined
59
+ readonly item: InputField
60
+ readonly minItems: number
61
+ readonly maxItems: number | undefined
62
+ }
63
+ | {
64
+ readonly _tag: "Union"
65
+ readonly title: string | undefined
66
+ readonly description: string | undefined
67
+ readonly alternatives: ReadonlyArray<InputField>
68
+ }
69
+ | {
70
+ readonly _tag: "Unsupported"
71
+ readonly title: string | undefined
72
+ readonly description: string | undefined
73
+ readonly reason: string
74
+ }
75
+
76
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
77
+ typeof value === "object" && value !== null && !Array.isArray(value)
78
+
79
+ const stringValue = (value: unknown): string | undefined => typeof value === "string" ? value : undefined
80
+
81
+ const numberValue = (value: unknown): number | undefined =>
82
+ typeof value === "number" && Number.isFinite(value) ? value : undefined
83
+
84
+ const primitiveValue = (value: unknown): JsonPrimitive | undefined =>
85
+ value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean"
86
+ ? value
87
+ : undefined
88
+
89
+ const resolveReference = (
90
+ value: unknown,
91
+ definitions: Readonly<Record<string, unknown>>,
92
+ seen: ReadonlySet<string> = new Set()
93
+ ): unknown => {
94
+ if (!isRecord(value) || typeof value.$ref !== "string" || !value.$ref.startsWith("#/$defs/")) return value
95
+ const name = decodeURIComponent(value.$ref.slice("#/$defs/".length))
96
+ if (seen.has(name)) return undefined
97
+ const next = definitions[name]
98
+ return resolveReference(next, definitions, new Set([...seen, name]))
99
+ }
100
+
101
+ const annotations = (schema: Record<string, unknown>) => ({
102
+ title: stringValue(schema.title),
103
+ description: stringValue(schema.description)
104
+ })
105
+
106
+ const mergeAllOf = (
107
+ schema: Record<string, unknown>,
108
+ definitions: Readonly<Record<string, unknown>>
109
+ ): Record<string, unknown> => {
110
+ if (!Array.isArray(schema.allOf)) return schema
111
+ const { allOf, ...base } = schema
112
+ return Object.assign(
113
+ base,
114
+ ...allOf
115
+ .map((part) => resolveReference(part, definitions))
116
+ .filter(isRecord)
117
+ .map((part) => mergeAllOf(part, definitions))
118
+ )
119
+ }
120
+
121
+ const effectNumberAlternative = (
122
+ alternatives: ReadonlyArray<unknown>,
123
+ definitions: Readonly<Record<string, unknown>>
124
+ ): Record<string, unknown> | undefined => {
125
+ if (alternatives.length !== 2) return undefined
126
+ const resolved = alternatives.map((alternative) => resolveReference(alternative, definitions))
127
+ const number = resolved.find((alternative) => isRecord(alternative) && alternative.type === "number")
128
+ const encodedNonFinite = resolved.find((alternative) => {
129
+ if (!isRecord(alternative) || alternative.type !== "string" || !Array.isArray(alternative.enum)) return false
130
+ const enumValues: ReadonlyArray<unknown> = alternative.enum
131
+ return enumValues.length === 3 &&
132
+ ["Infinity", "-Infinity", "NaN"].every((value) => enumValues.includes(value))
133
+ })
134
+ return isRecord(number) && encodedNonFinite !== undefined ? number : undefined
135
+ }
136
+
137
+ const project = (
138
+ value: unknown,
139
+ definitions: Readonly<Record<string, unknown>>
140
+ ): InputField => {
141
+ const referenced = resolveReference(value, definitions)
142
+ if (!isRecord(referenced)) {
143
+ return {
144
+ _tag: "Unsupported",
145
+ title: undefined,
146
+ description: undefined,
147
+ reason: "This input schema cannot be represented as fields."
148
+ }
149
+ }
150
+ const resolved = mergeAllOf(referenced, definitions)
151
+ const common = annotations(resolved)
152
+ const alternatives = Array.isArray(resolved.oneOf)
153
+ ? resolved.oneOf
154
+ : Array.isArray(resolved.anyOf)
155
+ ? resolved.anyOf
156
+ : undefined
157
+ if (alternatives !== undefined) {
158
+ const effectNumber = effectNumberAlternative(alternatives, definitions)
159
+ if (effectNumber !== undefined) {
160
+ return {
161
+ _tag: "Number",
162
+ ...common,
163
+ defaultValue: numberValue(resolved.default ?? effectNumber.default),
164
+ integer: false,
165
+ minimum: numberValue(resolved.minimum ?? effectNumber.minimum),
166
+ maximum: numberValue(resolved.maximum ?? effectNumber.maximum)
167
+ }
168
+ }
169
+ return {
170
+ _tag: "Union",
171
+ ...common,
172
+ alternatives: alternatives.map((alternative) => project(alternative, definitions))
173
+ }
174
+ }
175
+ const constant = primitiveValue(resolved.const)
176
+ if (constant !== undefined || resolved.const === null) {
177
+ return { _tag: "Literal", ...common, value: constant ?? null }
178
+ }
179
+ if (Array.isArray(resolved.enum)) {
180
+ const values = resolved.enum.map(primitiveValue).filter((item): item is JsonPrimitive => item !== undefined)
181
+ if (values.length > 0) {
182
+ return {
183
+ _tag: "Enum",
184
+ ...common,
185
+ values,
186
+ defaultValue: primitiveValue(resolved.default)
187
+ }
188
+ }
189
+ }
190
+ switch (resolved.type) {
191
+ case "string":
192
+ return {
193
+ _tag: "String",
194
+ ...common,
195
+ defaultValue: stringValue(resolved.default),
196
+ format: stringValue(resolved.format),
197
+ minLength: numberValue(resolved.minLength),
198
+ maxLength: numberValue(resolved.maxLength),
199
+ pattern: stringValue(resolved.pattern)
200
+ }
201
+ case "integer":
202
+ case "number":
203
+ return {
204
+ _tag: "Number",
205
+ ...common,
206
+ defaultValue: numberValue(resolved.default),
207
+ integer: resolved.type === "integer",
208
+ minimum: numberValue(resolved.minimum),
209
+ maximum: numberValue(resolved.maximum)
210
+ }
211
+ case "boolean":
212
+ return {
213
+ _tag: "Boolean",
214
+ ...common,
215
+ defaultValue: typeof resolved.default === "boolean" ? resolved.default : undefined
216
+ }
217
+ case "null":
218
+ return { _tag: "Literal", ...common, value: null }
219
+ case "object": {
220
+ if (!isRecord(resolved.properties)) {
221
+ return { _tag: "Object", ...common, fields: [] }
222
+ }
223
+ const required = new Set(
224
+ Array.isArray(resolved.required)
225
+ ? resolved.required.filter((item): item is string => typeof item === "string")
226
+ : []
227
+ )
228
+ return {
229
+ _tag: "Object",
230
+ ...common,
231
+ fields: Object.entries(resolved.properties).map(([key, child]) => ({
232
+ key,
233
+ required: required.has(key),
234
+ field: project(child, definitions)
235
+ }))
236
+ }
237
+ }
238
+ case "array":
239
+ return {
240
+ _tag: "Array",
241
+ ...common,
242
+ item: project(resolved.items, definitions),
243
+ minItems: numberValue(resolved.minItems) ?? 0,
244
+ maxItems: numberValue(resolved.maxItems)
245
+ }
246
+ default:
247
+ return {
248
+ _tag: "Unsupported",
249
+ ...common,
250
+ reason: "This input schema has no concrete JSON shape to render."
251
+ }
252
+ }
253
+ }
254
+
255
+ export const projectInputSchema = (document: InputSchema): InputField => project(document.schema, document.definitions)
256
+
257
+ export interface InputFormResult {
258
+ readonly ok: boolean
259
+ readonly value?: unknown
260
+ }
261
+
262
+ export interface InputForm {
263
+ readonly element: HTMLFormElement
264
+ readonly hasFields: boolean
265
+ readonly supported: boolean
266
+ readonly read: () => InputFormResult
267
+ readonly clearIssues: () => void
268
+ readonly setIssues: (issues: ReadonlyArray<InputIssue>) => void
269
+ readonly setPending: (pending: boolean) => void
270
+ }
271
+
272
+ interface Control {
273
+ readonly element: HTMLElement
274
+ readonly interactive: boolean
275
+ readonly supported: boolean
276
+ readonly read: () => unknown
277
+ }
278
+
279
+ interface RenderContext {
280
+ readonly issueTargets: Map<string, HTMLElement>
281
+ }
282
+
283
+ let nextControlId = 0
284
+
285
+ const element = <Tag extends keyof HTMLElementTagNameMap>(
286
+ tag: Tag,
287
+ className?: string,
288
+ text?: string
289
+ ): HTMLElementTagNameMap[Tag] => {
290
+ const node = document.createElement(tag)
291
+ if (className !== undefined) node.className = className
292
+ if (text !== undefined) node.textContent = text
293
+ return node
294
+ }
295
+
296
+ const valueKey = (value: JsonPrimitive): string => JSON.stringify(value)
297
+
298
+ const labelText = (field: InputField, fallback: string): string => field.title ?? fallback
299
+
300
+ const description = (field: InputField): HTMLElement | undefined =>
301
+ field.description === undefined ? undefined : element("p", "input-description", field.description)
302
+
303
+ const fieldType = (field: InputField): string => {
304
+ switch (field._tag) {
305
+ case "String":
306
+ return field.format ?? "string"
307
+ case "Number":
308
+ return field.integer ? "integer" : "number"
309
+ case "Boolean":
310
+ return "boolean"
311
+ case "Enum":
312
+ return "enum"
313
+ case "Literal":
314
+ return "literal"
315
+ case "Object":
316
+ return "object"
317
+ case "Array":
318
+ return `${fieldType(field.item)}[]`
319
+ case "Union":
320
+ return "union"
321
+ case "Unsupported":
322
+ return "unsupported"
323
+ }
324
+ }
325
+
326
+ const fieldConstraints = (field: InputField): ReadonlyArray<string> => {
327
+ switch (field._tag) {
328
+ case "String":
329
+ return [
330
+ field.minLength === undefined ? undefined : `min ${field.minLength} characters`,
331
+ field.maxLength === undefined ? undefined : `max ${field.maxLength} characters`,
332
+ field.pattern === undefined ? undefined : `pattern ${field.pattern}`,
333
+ field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
334
+ ].filter((value): value is string => value !== undefined)
335
+ case "Number":
336
+ return [
337
+ field.minimum === undefined ? undefined : `min ${field.minimum}`,
338
+ field.maximum === undefined ? undefined : `max ${field.maximum}`,
339
+ field.defaultValue === undefined ? undefined : `default ${field.defaultValue}`
340
+ ].filter((value): value is string => value !== undefined)
341
+ case "Enum":
342
+ return [
343
+ `${field.values.length} options`,
344
+ field.defaultValue === undefined ? undefined : `default ${String(field.defaultValue)}`
345
+ ].filter((value): value is string => value !== undefined)
346
+ case "Literal":
347
+ return [`fixed ${String(field.value)}`]
348
+ case "Object":
349
+ return [`${field.fields.length} fields`]
350
+ case "Array":
351
+ return [
352
+ field.minItems === 0 ? undefined : `min ${field.minItems} items`,
353
+ field.maxItems === undefined ? undefined : `max ${field.maxItems} items`
354
+ ].filter((value): value is string => value !== undefined)
355
+ case "Union":
356
+ return [`${field.alternatives.length} variants`]
357
+ case "Boolean":
358
+ case "Unsupported":
359
+ return []
360
+ }
361
+ }
362
+
363
+ const renderControl = (
364
+ field: InputField,
365
+ name: string,
366
+ context: RenderContext,
367
+ path: ReadonlyArray<string | number>
368
+ ): Control => {
369
+ switch (field._tag) {
370
+ case "String": {
371
+ const input = element("input", "input-control")
372
+ input.type = field.format === "date-time"
373
+ ? "datetime-local"
374
+ : field.format === "date"
375
+ ? "date"
376
+ : field.format === "email"
377
+ ? "email"
378
+ : field.format === "uri"
379
+ ? "url"
380
+ : "text"
381
+ input.name = name
382
+ input.value = field.defaultValue ?? ""
383
+ if (field.minLength !== undefined) input.minLength = field.minLength
384
+ if (field.maxLength !== undefined) input.maxLength = field.maxLength
385
+ if (field.pattern !== undefined) input.pattern = field.pattern
386
+ return { element: input, interactive: true, supported: true, read: () => input.value }
387
+ }
388
+ case "Number": {
389
+ const input = element("input", "input-control")
390
+ input.type = "number"
391
+ input.name = name
392
+ input.step = field.integer ? "1" : "any"
393
+ if (field.defaultValue !== undefined) input.value = String(field.defaultValue)
394
+ if (field.minimum !== undefined) input.min = String(field.minimum)
395
+ if (field.maximum !== undefined) input.max = String(field.maximum)
396
+ return {
397
+ element: input,
398
+ interactive: true,
399
+ supported: true,
400
+ read: () => input.value === "" ? undefined : Number(input.value)
401
+ }
402
+ }
403
+ case "Boolean": {
404
+ const wrapper = element("label", "boolean-control")
405
+ const input = element("input")
406
+ input.type = "checkbox"
407
+ input.name = name
408
+ input.checked = field.defaultValue ?? false
409
+ wrapper.append(input, element("span", undefined, "Enabled"))
410
+ return { element: wrapper, interactive: true, supported: true, read: () => input.checked }
411
+ }
412
+ case "Enum": {
413
+ const select = element("select", "input-control")
414
+ select.name = name
415
+ field.values.forEach((value) => {
416
+ const option = element("option", undefined, String(value))
417
+ option.value = valueKey(value)
418
+ option.selected = Object.is(value, field.defaultValue)
419
+ select.append(option)
420
+ })
421
+ return {
422
+ element: select,
423
+ interactive: true,
424
+ supported: true,
425
+ read: () => JSON.parse(select.value) as JsonPrimitive
426
+ }
427
+ }
428
+ case "Literal": {
429
+ const output = element("output", "literal-control", String(field.value))
430
+ return { element: output, interactive: false, supported: true, read: () => field.value }
431
+ }
432
+ case "Object": {
433
+ const group = element("fieldset", "input-object")
434
+ const legend = element("legend", undefined, labelText(field, name))
435
+ group.append(legend)
436
+ const controls: Array<{
437
+ readonly key: string
438
+ readonly included: HTMLInputElement | undefined
439
+ readonly control: Control
440
+ }> = []
441
+ for (const property of field.fields) {
442
+ const row = element("div", "input-field")
443
+ const heading = element("div", "input-field-heading")
444
+ const label = element("label", "input-label", labelText(property.field, property.key))
445
+ const identity = element("div", "input-field-identity")
446
+ identity.append(label, element("span", "input-field-type", fieldType(property.field)))
447
+ const required = property.required ? element("span", "input-required", "required") : undefined
448
+ let included: HTMLInputElement | undefined
449
+ const propertyPath = [...path, property.key]
450
+ const control = renderControl(property.field, `${name}.${property.key}`, context, propertyPath)
451
+ const labelled = control.element instanceof HTMLInputElement || control.element instanceof HTMLSelectElement
452
+ ? control.element
453
+ : control.element.querySelector<HTMLInputElement | HTMLSelectElement>(":scope > input, :scope > select")
454
+ if (labelled !== null) {
455
+ const id = `machine-input-${nextControlId++}`
456
+ labelled.id = id
457
+ label.htmlFor = id
458
+ }
459
+ if (property.required) {
460
+ if (control.element instanceof HTMLSelectElement) {
461
+ control.element.required = true
462
+ } else if (control.element instanceof HTMLInputElement) {
463
+ control.element.required = property.field._tag === "Number" ||
464
+ (property.field._tag === "String" &&
465
+ (property.field.format !== undefined || (property.field.minLength ?? 0) > 0))
466
+ }
467
+ heading.append(identity, required!)
468
+ } else {
469
+ const optional = element("label", "input-optional")
470
+ included = element("input")
471
+ included.type = "checkbox"
472
+ optional.append(included, element("span", undefined, "include"))
473
+ heading.append(identity, optional)
474
+ control.element.toggleAttribute("inert", true)
475
+ control.element.classList.add("is-disabled")
476
+ control.element.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
477
+ "input, select, button"
478
+ ).forEach((item) => item.disabled = true)
479
+ if (
480
+ control.element instanceof HTMLInputElement ||
481
+ control.element instanceof HTMLSelectElement ||
482
+ control.element instanceof HTMLButtonElement
483
+ ) control.element.disabled = true
484
+ included.addEventListener("change", () => {
485
+ control.element.toggleAttribute("inert", !included!.checked)
486
+ control.element.classList.toggle("is-disabled", !included!.checked)
487
+ control.element.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLButtonElement>(
488
+ "input, select, button"
489
+ ).forEach((item) => item.disabled = !included!.checked)
490
+ if (
491
+ control.element instanceof HTMLInputElement ||
492
+ control.element instanceof HTMLSelectElement ||
493
+ control.element instanceof HTMLButtonElement
494
+ ) control.element.disabled = !included!.checked
495
+ })
496
+ }
497
+ const issues = element("div", "input-errors")
498
+ issues.hidden = true
499
+ context.issueTargets.set(JSON.stringify(propertyPath), issues)
500
+ row.append(heading, control.element)
501
+ const constraints = fieldConstraints(property.field)
502
+ if (constraints.length > 0) {
503
+ const metadata = element("div", "input-constraints")
504
+ constraints.forEach((constraint) => metadata.append(element("span", undefined, constraint)))
505
+ row.append(metadata)
506
+ }
507
+ const details = description(property.field)
508
+ if (details !== undefined) row.append(details)
509
+ row.append(issues)
510
+ group.append(row)
511
+ controls.push({ key: property.key, included, control })
512
+ }
513
+ return {
514
+ element: group,
515
+ interactive: controls.some(({ control }) => control.interactive),
516
+ supported: controls.every(({ control }) => control.supported),
517
+ read: () =>
518
+ Object.fromEntries(
519
+ controls
520
+ .filter(({ included }) => included === undefined || included.checked)
521
+ .map(({ key, control }) => [key, control.read()])
522
+ .filter((entry) => entry[1] !== undefined)
523
+ )
524
+ }
525
+ }
526
+ case "Array": {
527
+ const group = element("fieldset", "input-array")
528
+ group.append(element("legend", undefined, labelText(field, name)))
529
+ const items = element("div", "input-array-items")
530
+ const controls: Array<{
531
+ readonly row: HTMLElement
532
+ readonly control: Control
533
+ readonly remove: HTMLButtonElement
534
+ }> = []
535
+ const add = element("button", "input-array-add", "Add item")
536
+ add.type = "button"
537
+ const refreshActions = (): void => {
538
+ add.disabled = field.maxItems !== undefined && controls.length >= field.maxItems
539
+ controls.forEach(({ remove }) => remove.disabled = controls.length <= field.minItems)
540
+ }
541
+ const addItem = (): void => {
542
+ if (field.maxItems !== undefined && controls.length >= field.maxItems) return
543
+ const row = element("div", "input-array-item")
544
+ const control = renderControl(field.item, `${name}.${controls.length}`, context, [...path, controls.length])
545
+ const remove = element("button", "input-array-remove", "Remove")
546
+ remove.type = "button"
547
+ remove.addEventListener("click", () => {
548
+ row.remove()
549
+ const index = controls.findIndex((item) => item.row === row)
550
+ if (index >= 0) controls.splice(index, 1)
551
+ refreshActions()
552
+ })
553
+ row.append(control.element, remove)
554
+ items.append(row)
555
+ controls.push({ row, control, remove })
556
+ refreshActions()
557
+ }
558
+ for (let index = 0; index < field.minItems; index++) addItem()
559
+ add.addEventListener("click", addItem)
560
+ group.append(items, add)
561
+ return {
562
+ element: group,
563
+ interactive: true,
564
+ supported: controls.every(({ control }) => control.supported) && field.item._tag !== "Unsupported",
565
+ read: () => controls.map(({ control }) => control.read())
566
+ }
567
+ }
568
+ case "Union": {
569
+ const group = element("fieldset", "input-union")
570
+ group.append(element("legend", undefined, labelText(field, name)))
571
+ const select = element("select", "input-control")
572
+ const body = element("div", "input-union-body")
573
+ let selected = 0
574
+ const controls = field.alternatives.map((alternative, index) => {
575
+ const control = renderControl(alternative, `${name}.${index}`, context, path)
576
+ const option = element("option", undefined, labelText(alternative, `Option ${index + 1}`))
577
+ option.value = String(index)
578
+ select.append(option)
579
+ return control
580
+ })
581
+ const update = (): void => {
582
+ selected = Number(select.value)
583
+ body.replaceChildren(controls[selected]?.element ?? element("div"))
584
+ }
585
+ select.addEventListener("change", update)
586
+ group.append(select, body)
587
+ update()
588
+ return {
589
+ element: group,
590
+ interactive: true,
591
+ supported: controls.every(({ supported }) => supported),
592
+ read: () => controls[selected]?.read()
593
+ }
594
+ }
595
+ case "Unsupported": {
596
+ const message = element("p", "input-unsupported", field.reason)
597
+ return { element: message, interactive: false, supported: false, read: () => undefined }
598
+ }
599
+ }
600
+ }
601
+
602
+ export const renderInputForm = (
603
+ schema: InputSchema,
604
+ options: {
605
+ readonly name: string
606
+ readonly fixed?: Readonly<Record<string, JsonPrimitive>>
607
+ readonly omit?: ReadonlyArray<string>
608
+ }
609
+ ): InputForm => {
610
+ const form = element("form", "schema-form")
611
+ const formIssues = element("div", "input-errors input-errors-form")
612
+ formIssues.hidden = true
613
+ const context: RenderContext = { issueTargets: new Map() }
614
+ const projected = projectInputSchema(schema)
615
+ const visible = projected._tag === "Object" && options.omit !== undefined
616
+ ? { ...projected, fields: projected.fields.filter(({ key }) => !options.omit!.includes(key)) }
617
+ : projected
618
+ const control = renderControl(visible, options.name, context, [])
619
+ form.append(formIssues, control.element)
620
+ const clearIssues = (): void => {
621
+ formIssues.replaceChildren()
622
+ formIssues.hidden = true
623
+ context.issueTargets.forEach((target) => {
624
+ target.replaceChildren()
625
+ target.hidden = true
626
+ })
627
+ }
628
+ const setIssues = (issues: ReadonlyArray<InputIssue>): void => {
629
+ clearIssues()
630
+ const grouped = new Map<HTMLElement, Array<string>>()
631
+ for (const issue of issues) {
632
+ let target: HTMLElement = formIssues
633
+ for (let length = issue.path.length; length > 0; length--) {
634
+ const candidate = context.issueTargets.get(JSON.stringify(issue.path.slice(0, length)))
635
+ if (candidate !== undefined) {
636
+ target = candidate
637
+ break
638
+ }
639
+ }
640
+ const messages = grouped.get(target) ?? []
641
+ if (!messages.includes(issue.message)) messages.push(issue.message)
642
+ grouped.set(target, messages)
643
+ }
644
+ grouped.forEach((messages, target) => {
645
+ messages.forEach((message) => target.append(element("div", undefined, message)))
646
+ target.hidden = false
647
+ })
648
+ }
649
+ return {
650
+ element: form,
651
+ hasFields: control.interactive,
652
+ supported: control.supported,
653
+ read: () => {
654
+ clearIssues()
655
+ if (!form.reportValidity() || !control.supported) return { ok: false }
656
+ const value = control.read()
657
+ return {
658
+ ok: true,
659
+ value: isRecord(value) && options.fixed !== undefined ? { ...value, ...options.fixed } : value
660
+ }
661
+ },
662
+ clearIssues,
663
+ setIssues,
664
+ setPending: (pending) => {
665
+ form.toggleAttribute("inert", pending)
666
+ form.setAttribute("aria-busy", String(pending))
667
+ }
668
+ }
669
+ }