@nan0web/ui 1.0.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 (131) hide show
  1. package/.datasets/README.dataset.jsonl +12 -0
  2. package/.editorconfig +20 -0
  3. package/CONTRIBUTING.md +42 -0
  4. package/LICENSE +15 -0
  5. package/README.md +238 -0
  6. package/docs/uk/README.md +240 -0
  7. package/package.json +64 -0
  8. package/playground/User.js +52 -0
  9. package/playground/currency.exchange.js +48 -0
  10. package/playground/i18n/index.js +21 -0
  11. package/playground/i18n/uk.js +53 -0
  12. package/playground/language.form.js +25 -0
  13. package/playground/main.js +72 -0
  14. package/playground/registration.form.js +58 -0
  15. package/playground/topup.telephone.js +62 -0
  16. package/src/App/Command/Options.js +78 -0
  17. package/src/App/Command/index.js +9 -0
  18. package/src/App/Core/CoreApp.js +129 -0
  19. package/src/App/Core/UI.js +116 -0
  20. package/src/App/Core/Widget.js +67 -0
  21. package/src/App/Core/index.js +11 -0
  22. package/src/App/Scenario.js +45 -0
  23. package/src/App/User/Command/Message.js +44 -0
  24. package/src/App/User/Command/Options.js +48 -0
  25. package/src/App/User/Command/index.js +11 -0
  26. package/src/App/User/UserApp.js +73 -0
  27. package/src/App/User/UserApp.test.js +56 -0
  28. package/src/App/User/UserUI.js +20 -0
  29. package/src/App/User/UserUI.test.js +51 -0
  30. package/src/App/User/index.js +15 -0
  31. package/src/App/index.js +22 -0
  32. package/src/Component/Process/Input.js +70 -0
  33. package/src/Component/Process/Process.js +26 -0
  34. package/src/Component/Process/index.js +5 -0
  35. package/src/Component/Welcome/Input.js +50 -0
  36. package/src/Component/Welcome/Welcome.js +26 -0
  37. package/src/Component/Welcome/index.js +5 -0
  38. package/src/Component/index.js +9 -0
  39. package/src/Frame/Frame.js +591 -0
  40. package/src/Frame/Frame.test.js +429 -0
  41. package/src/Frame/Props.js +102 -0
  42. package/src/Locale.js +119 -0
  43. package/src/Model/User/User.js +56 -0
  44. package/src/Model/index.js +7 -0
  45. package/src/README.md.js +371 -0
  46. package/src/StdIn.js +111 -0
  47. package/src/StdOut.js +99 -0
  48. package/src/View/RenderOptions.js +48 -0
  49. package/src/View/View.js +289 -0
  50. package/src/View/View.test.js +77 -0
  51. package/src/core/Form/Form.js +289 -0
  52. package/src/core/Form/Form.test.js +116 -0
  53. package/src/core/Form/Input.js +116 -0
  54. package/src/core/Form/Input.test.js +58 -0
  55. package/src/core/Form/Message.js +86 -0
  56. package/src/core/Form/Message.test.js +54 -0
  57. package/src/core/Form/index.js +11 -0
  58. package/src/core/InputAdapter.js +41 -0
  59. package/src/core/InputAdapter.test.js +35 -0
  60. package/src/core/Message/InputMessage.js +119 -0
  61. package/src/core/Message/InputMessage.test.js +45 -0
  62. package/src/core/Message/Message.js +77 -0
  63. package/src/core/Message/Message.test.js +58 -0
  64. package/src/core/Message/OutputMessage.js +143 -0
  65. package/src/core/Message/OutputMessage.test.js +61 -0
  66. package/src/core/Message/index.js +7 -0
  67. package/src/core/OutputAdapter.js +50 -0
  68. package/src/core/OutputAdapter.test.js +35 -0
  69. package/src/core/Stream.js +71 -0
  70. package/src/core/Stream.test.js +78 -0
  71. package/src/core/StreamEntry.js +59 -0
  72. package/src/core/index.js +13 -0
  73. package/src/functions.js +38 -0
  74. package/src/index.js +34 -0
  75. package/src/index.test.js +14 -0
  76. package/src/models/SimpleUser.js +18 -0
  77. package/stories/App/AppView.js +15 -0
  78. package/stories/App/AppView.test.js +22 -0
  79. package/stories/App/RenderOptions.js +14 -0
  80. package/stories/nodejs/interface.test.js +27 -0
  81. package/system.md +187 -0
  82. package/system1.md +137 -0
  83. package/task.md +181 -0
  84. package/tsconfig.json +23 -0
  85. package/types/App/Command/Options.d.ts +46 -0
  86. package/types/App/Command/index.d.ts +8 -0
  87. package/types/App/Core/CoreApp.d.ts +70 -0
  88. package/types/App/Core/UI.d.ts +49 -0
  89. package/types/App/Core/Widget.d.ts +40 -0
  90. package/types/App/Core/index.d.ts +10 -0
  91. package/types/App/Scenario.d.ts +26 -0
  92. package/types/App/User/Command/Message.d.ts +30 -0
  93. package/types/App/User/Command/Options.d.ts +27 -0
  94. package/types/App/User/Command/index.d.ts +8 -0
  95. package/types/App/User/UserApp.d.ts +31 -0
  96. package/types/App/User/UserUI.d.ts +18 -0
  97. package/types/App/User/index.d.ts +12 -0
  98. package/types/App/index.d.ts +14 -0
  99. package/types/Component/Process/Input.d.ts +48 -0
  100. package/types/Component/Process/Process.d.ts +13 -0
  101. package/types/Component/Process/index.d.ts +4 -0
  102. package/types/Component/Welcome/Input.d.ts +34 -0
  103. package/types/Component/Welcome/Welcome.d.ts +13 -0
  104. package/types/Component/Welcome/index.d.ts +4 -0
  105. package/types/Component/index.d.ts +8 -0
  106. package/types/Frame/Frame.d.ts +186 -0
  107. package/types/Frame/Props.d.ts +77 -0
  108. package/types/Locale.d.ts +55 -0
  109. package/types/Model/User/User.d.ts +36 -0
  110. package/types/Model/index.d.ts +6 -0
  111. package/types/StdIn.d.ts +62 -0
  112. package/types/StdOut.d.ts +52 -0
  113. package/types/View/RenderOptions.d.ts +29 -0
  114. package/types/View/View.d.ts +115 -0
  115. package/types/core/Form/Form.d.ts +123 -0
  116. package/types/core/Form/Input.d.ts +69 -0
  117. package/types/core/Form/Message.d.ts +28 -0
  118. package/types/core/Form/index.d.ts +5 -0
  119. package/types/core/InputAdapter.d.ts +28 -0
  120. package/types/core/Message/InputMessage.d.ts +71 -0
  121. package/types/core/Message/Message.d.ts +50 -0
  122. package/types/core/Message/OutputMessage.d.ts +53 -0
  123. package/types/core/Message/index.d.ts +5 -0
  124. package/types/core/OutputAdapter.d.ts +33 -0
  125. package/types/core/Stream.d.ts +27 -0
  126. package/types/core/StreamEntry.d.ts +45 -0
  127. package/types/core/index.d.ts +9 -0
  128. package/types/functions.d.ts +3 -0
  129. package/types/index.d.ts +20 -0
  130. package/types/models/SimpleUser.d.ts +21 -0
  131. package/vitest.config.js +26 -0
@@ -0,0 +1,289 @@
1
+ import { empty, equal, typeOf } from "@nan0web/types"
2
+ import Frame, { FrameRenderMethod } from "../Frame/Frame.js"
3
+ import Locale from "../Locale.js"
4
+ import StdOut from "../StdOut.js"
5
+ import StdIn from "../StdIn.js"
6
+ import InputMessage from "../core/Message/InputMessage.js"
7
+ import RenderOptions from "./RenderOptions.js"
8
+
9
+ /**
10
+ * @typedef {Object} ComponentFn
11
+ * @property {string} name
12
+ * @property {(input: InputMessage) => Promise<any>} ask
13
+ * @property {Function} bind
14
+ */
15
+
16
+ export default class View {
17
+ /** @type {typeof RenderOptions} */
18
+ static RenderOptions = RenderOptions
19
+ /** @type {typeof FrameRenderMethod} */
20
+ static RenderMethod = Frame.RenderMethod
21
+ /** @type {StdIn} */
22
+ stdin
23
+ /** @type {StdOut} */
24
+ stdout
25
+ /** @type {number} */
26
+ startedAt
27
+ /** @type {Frame} */
28
+ frame
29
+ /** @type {Locale} */
30
+ locale
31
+ /** @type {Map} */
32
+ vocab
33
+ /** @type {number[]} */
34
+ windowSize
35
+ /** @type {Map<string, ComponentFn>} */
36
+ components
37
+ /** @type {string} */
38
+ renderMethod
39
+ /**
40
+ * @param {object} [input]
41
+ * @param {StdIn} [input.stdin]
42
+ * @param {StdOut} [input.stdout]
43
+ * @param {number} [input.startedAt]
44
+ * @param {Frame} [input.frame]
45
+ * @param {Locale} [input.locale]
46
+ * @param {Map} [input.vocab]
47
+ * @param {number[]} [input.windowSize]
48
+ * @param {Map<string, ComponentFn>} [input.components]
49
+ * @param {string} [input.renderMethod]
50
+ */
51
+ constructor(input = {}) {
52
+ const {
53
+ stdin = new StdIn(),
54
+ stdout = new StdOut(),
55
+ startedAt = Date.now(),
56
+ frame = new Frame(),
57
+ locale = Locale.from("uk-UA"),
58
+ vocab = new Map(),
59
+ windowSize = [0, 0],
60
+ components = new Map(),
61
+ renderMethod = Frame.RenderMethod.VISIBLE,
62
+ } = input
63
+ this.stdin = stdin
64
+ this.stdout = stdout
65
+ this.startedAt = startedAt
66
+ this.frame = frame
67
+ this.locale = locale
68
+ this.vocab = vocab
69
+ this.windowSize = null === windowSize ? this.stdout.getWindowSize() : windowSize
70
+ this.components = components
71
+ this.renderMethod = renderMethod
72
+ if (!empty(frame)) {
73
+ this.render(1)(this.frame)
74
+ }
75
+ }
76
+ get empty() {
77
+ return empty(this.frame)
78
+ }
79
+ get RenderMethod() {
80
+ return /** @type {typeof View} */ (this.constructor).RenderMethod
81
+ }
82
+ get RenderOptions() {
83
+ return /** @type {typeof View} */ (this.constructor).RenderOptions
84
+ }
85
+ getWindowSize() {
86
+ return equal(this.windowSize, [0, 0]) ? this.stdout.getWindowSize() : this.windowSize
87
+ }
88
+ setWindowSize(width, height) {
89
+ this.windowSize = [width, height]
90
+ }
91
+ startTimer() {
92
+ this.startedAt = Date.now()
93
+ }
94
+ spent(checkpoint = this.startedAt) {
95
+ return Math.round((Date.now() - checkpoint) / 1000)
96
+ }
97
+ /**
98
+ * @param {boolean | number | Function | ComponentFn} [shouldRender=0]
99
+ * @param {RenderOptions} [options]
100
+ * @returns {(value: Frame|string|string[], ...args: any) => Frame}
101
+ */
102
+ render(shouldRender = 0, options = new this.RenderOptions()) {
103
+ const [width, height] = this.getWindowSize()
104
+ options = this.RenderOptions.from({
105
+ ...options,
106
+ renderMethod: this.renderMethod, width, height,
107
+ // @ts-ignore
108
+ })
109
+ const renderFn = "function" === typeof shouldRender // no errors.
110
+ // const renderFn = typeOf(Function)(shouldRender) // Property 'bind' does not exist on type 'number | boolean | Function'.
111
+ ? shouldRender.bind(this) : "string" === typeof shouldRender
112
+ ? this.components.get(shouldRender)?.bind(this)
113
+ : null
114
+
115
+ return (value, ...args) => {
116
+ if (renderFn) {
117
+ /** @type {Frame} */
118
+ let rendered = renderFn.bind(this)(value, ...args)
119
+ if (!(rendered instanceof Frame)) {
120
+ rendered = new Frame({
121
+ value: rendered,
122
+ renderMethod: options.renderMethod,
123
+ width: options.width,
124
+ height: options.height,
125
+ })
126
+ }
127
+ rendered = View.fixFrame(rendered, options)
128
+ rendered = rendered.transform(this.t.bind(this))
129
+ rendered.render()
130
+ if (options.render) {
131
+ this.stdout.write(String(rendered))
132
+ this.frame = rendered
133
+ }
134
+ return rendered
135
+ }
136
+
137
+ let frame = Frame.from({ ...options, value })
138
+ frame = View.fixFrame(frame, options)
139
+ let clearFrame = false
140
+ if (String(frame.value[0] ?? "") === Frame.BOF) {
141
+ frame.value = frame.value.slice(1)
142
+ clearFrame = true
143
+ }
144
+ let translated = options.translateFrame ? frame.transform(this.t.bind(this)) : frame
145
+ translated = View.fixFrame(translated, options)
146
+
147
+ let rendered = translated
148
+ rendered.render()
149
+ if (shouldRender) {
150
+ if (clearFrame) {
151
+ const distance = options.height - frame.value.length
152
+ this.stdout.write(Frame.BOF)
153
+ for (let i = 0; i < distance; i++) {
154
+ this.stdout.write(Frame.EOL)
155
+ }
156
+ }
157
+ this.stdout.write(Frame.RESET + String(rendered))
158
+ this.frame = rendered
159
+ }
160
+ return rendered
161
+ }
162
+ }
163
+
164
+ clear(shouldRender = 0) {
165
+ const [width, height] = this.windowSize
166
+ const frame = new Frame({ width, height, renderMethod: Frame.RenderMethod.REPLACE })
167
+ frame.value = []
168
+ for (let i = 0; i < height; i++) frame.value.push([])
169
+ return this.render(shouldRender)(frame)
170
+ }
171
+
172
+ progress(shouldRender = false) {
173
+ return (value) => {
174
+ const frame = Frame.from(value)
175
+ frame.renderMethod = Frame.RenderMethod.REPLACE
176
+ return this.render(!!shouldRender)(frame)
177
+ }
178
+ }
179
+
180
+ t(value) {
181
+ if (typeOf(Array)(value)) {
182
+ value = value.map(row => {
183
+ if (typeOf(Array)(row)) {
184
+ return row.map(col => {
185
+ return this.vocab.has(col) ? this.vocab.get(col) : col
186
+ })
187
+ }
188
+ return this.vocab.has(row) ? this.vocab.get(row) : row
189
+ })
190
+ return value
191
+ }
192
+ return this.vocab.has(value) ? this.vocab.get(value) : value
193
+ }
194
+
195
+ debug(...args) {
196
+ return this.render(1)(
197
+ [StdOut.STYLES.dim,
198
+ "Debug: ", args.join(" "), Frame.EOL, StdOut.RESET],
199
+ )
200
+ }
201
+
202
+ info(...args) {
203
+ return this.render(1)(
204
+ [StdOut.COLORS.green,
205
+ "Info : ", args.join(" "), Frame.EOL, StdOut.RESET],
206
+ )
207
+ }
208
+
209
+ warn(...args) {
210
+ return this.render(1)(
211
+ [StdOut.COLORS.yellow,
212
+ "Warn : ", args.join(" "), Frame.EOL, StdOut.RESET],
213
+ )
214
+ }
215
+
216
+ error(...args) {
217
+ return this.render(1)(
218
+ [StdOut.COLORS.red, StdOut.STYLES.bold,
219
+ "Error: ", args.join(" "), Frame.EOL, StdOut.RESET],
220
+ )
221
+ }
222
+
223
+ /**
224
+ * @param {string} name
225
+ * @param {ComponentFn} component
226
+ */
227
+ register(name, component) {
228
+ if (undefined === component && "function" === typeof name) {
229
+ component = name
230
+ name = component.name
231
+ }
232
+ this.components.set(name, component)
233
+ }
234
+
235
+ /**
236
+ * @param {string} name
237
+ */
238
+ unregister(name) {
239
+ this.components.delete(name)
240
+ }
241
+
242
+ /**
243
+ * @param {string} name
244
+ * @returns {boolean}
245
+ */
246
+ has(name) {
247
+ return this.components.has(name)
248
+ }
249
+
250
+ /**
251
+ * @param {string} name
252
+ * @returns {ComponentFn | undefined}
253
+ */
254
+ get(name) {
255
+ return this.components.get(name)
256
+ }
257
+
258
+ /**
259
+ * @param {InputMessage} input
260
+ * @returns {Promise<InputMessage | null>}
261
+ */
262
+ async ask(input) {
263
+ const name = input.constructor.name.replace(/Input$/, "")
264
+ const component = this.get(name)
265
+ if (component) {
266
+ return await component.ask.apply(this, [input])
267
+ }
268
+ let result = null
269
+ do {
270
+ const answer = await this.stdin.read()
271
+ result = /** @type {typeof InputMessage} */ (input.constructor).from(answer)
272
+ } while (!result.isValid && !result.escaped)
273
+ return result.escaped ? null : result
274
+ }
275
+
276
+ /**
277
+ * @param {Frame} frame
278
+ * @param {RenderOptions} [options]
279
+ * @returns {Frame}
280
+ */
281
+ static fixFrame(frame, options = new RenderOptions()) {
282
+ if (options.resizeToView && !equal(options.width, frame.width, options.height, frame.height)) {
283
+ frame.setWindowSize(options.width, options.height)
284
+ }
285
+ // @todo add multiline visibility, for instance extended frame row into rows if it's wider than width.
286
+ return frame
287
+ }
288
+
289
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it } from "node:test"
2
+ import { strict as assert } from "node:assert"
3
+ import stringWidth from "string-width"
4
+ import View from "./View.js"
5
+ import Frame from "../Frame/Frame.js"
6
+ import FrameProps from "../Frame/Props.js"
7
+ import Welcome from "../Component/Welcome/index.js"
8
+
9
+ describe("View", () => {
10
+ it("should create empty instance", () => {
11
+ const view = new View()
12
+ assert.ok(view)
13
+ })
14
+ it("should print frame", () => {
15
+ const view = new View()
16
+ view.render(1)(["Hello"])
17
+ assert.equal(String(view.frame), Frame.CLEAR_LINE + "\r" + "Hello")
18
+ const value = view.render(0)(["No output"])
19
+ assert.deepStrictEqual({ ...value }, {
20
+ imprint: Frame.CLEAR_LINE + "\r" + "No output",
21
+ value: [["No output"]],
22
+ width: 144,
23
+ height: 33,
24
+ renderMethod: "visible",
25
+ defaultProps: new FrameProps(),
26
+ })
27
+ view.render(() => (["fn result"]))(["Even with a function"])
28
+ assert.equal(String(view.frame), Frame.CLEAR_LINE + "\r" + "fn result")
29
+ assert.deepStrictEqual(view.stdout.stream, [
30
+ Frame.RESET + Frame.CLEAR_LINE + "\r" + "Hello",
31
+ Frame.CLEAR_LINE + "\r" + "fn result"
32
+ ])
33
+ })
34
+ it("should print frame with render method set to append", () => {
35
+ const view = new View({ renderMethod: View.RenderMethod.APPEND })
36
+ view.render(1)(["Hello"])
37
+ assert.equal(String(view.frame), "Hello" + " ".repeat(139))
38
+ view.render(1)(["world"])
39
+ assert.equal(String(view.frame), "world" + " ".repeat(139))
40
+ const value = view.render(0)(["No output"])
41
+ assert.deepStrictEqual({ ...value }, {
42
+ imprint: "No output" + " ".repeat(144 - "No output".length),
43
+ value: [["No output"]],
44
+ width: 144,
45
+ height: 33,
46
+ renderMethod: View.RenderMethod.APPEND,
47
+ defaultProps: new FrameProps(),
48
+ })
49
+ view.render(() => (["fn result"]))(["Even with a function"])
50
+ assert.equal(String(view.frame), "fn result" + " ".repeat(144 - 9))
51
+ assert.deepStrictEqual(view.stdout.stream, [
52
+ Frame.RESET + "Hello",
53
+ Frame.RESET + "world",
54
+ "fn result"
55
+ ].map(
56
+ row => row + " ".repeat(144 - stringWidth(row))
57
+ ))
58
+ })
59
+ it("should render Welcome component", () => {
60
+ const view = new View({
61
+ renderMethod: View.RenderMethod.APPEND,
62
+ width: 144,
63
+ height: 33,
64
+ })
65
+ view.register(Welcome)
66
+ const frame = view.render("Welcome")({ user: { name: "View" } })
67
+ assert.ok(frame instanceof Frame)
68
+ const expected = [
69
+ "Welcome View!",
70
+ "What can we do today great?",
71
+ "",
72
+ ].map(
73
+ row => (row + " ".repeat(144 - row.length))
74
+ ).join("\n")
75
+ assert.deepStrictEqual(view.stdout.stream, [expected])
76
+ })
77
+ })
@@ -0,0 +1,289 @@
1
+ import FormMessage from "./Message.js"
2
+ import FormInput from "./Input.js"
3
+
4
+ /**
5
+ * Abstract form for data entry.
6
+ *
7
+ * @class UIForm
8
+ * @extends FormMessage
9
+ * @property {FormInput[]} fields - Form fields.
10
+ * @property {Object} state - Current form state (field values).
11
+ * @property {string} title - Form title.
12
+ * @property {Object} schema - Validation schema (optional).
13
+ */
14
+ export default class UIForm extends FormMessage {
15
+ /** @type {FormInput[]} */ fields = []
16
+ /** @type {Object} */ state = {}
17
+ /** @type {string} */ title = ''
18
+ /** @type {Object} */ schema = {}
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* static validator registry */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ /** @type {Object<string,Function>} */
25
+ static _validators = {}
26
+
27
+ /**
28
+ * Register a custom validator that can be referenced by name in a schema.
29
+ *
30
+ * @param {string} name - Identifier used in schema.validator.
31
+ * @param {(value:any)=>true|string} fn - Function returns true if valid,
32
+ * otherwise returns an error message.
33
+ */
34
+ static addValidator(name, fn) {
35
+ if (typeof name !== "string" || typeof fn !== "function") {
36
+ throw new Error("Validator name must be a string and fn must be a function")
37
+ }
38
+ UIForm._validators[name] = fn
39
+ }
40
+
41
+ /**
42
+ * Create a new UIForm.
43
+ *
44
+ * @param {Object} [props={}] - Form properties.
45
+ * @param {string} [props.title] - Form title.
46
+ * @param {FormInput[]} [props.fields=[]] - Form fields.
47
+ * @param {Object} [props.state={}] - Initial form state.
48
+ * @param {Object} [props.schema] - Validation schema.
49
+ */
50
+ constructor(props = {}) {
51
+ super(props)
52
+
53
+ const {
54
+ title = '',
55
+ fields = [],
56
+ state = {},
57
+ schema = {},
58
+ ...rest
59
+ } = props
60
+
61
+ // Normalise fields
62
+ this.fields = fields.map(f => FormInput.from(f))
63
+ this.title = title
64
+ this.state = { ...state }
65
+ this.schema = schema
66
+
67
+ // Update meta with form data
68
+ this.meta = {
69
+ title: this.title,
70
+ fields: this.fields.map(f => f.toJSON ? f.toJSON() : f),
71
+ initialState: this.state
72
+ }
73
+ }
74
+
75
+ /* ------------------------------------------------------------------ */
76
+ /* API */
77
+ /* ------------------------------------------------------------------ */
78
+
79
+ /**
80
+ * Returns a new UIForm instance with updated state.
81
+ *
82
+ * @param {Object} data - Partial state to merge.
83
+ * @returns {UIForm}
84
+ */
85
+ setData(data) {
86
+ return new UIForm({
87
+ ...this,
88
+ state: { ...this.state, ...data }
89
+ })
90
+ }
91
+
92
+ /**
93
+ * Retrieves a field definition by its name.
94
+ *
95
+ * @param {string} name - Field name.
96
+ * @returns {FormInput|undefined}
97
+ */
98
+ getField(name) {
99
+ return this.fields.find(f => f.name === name)
100
+ }
101
+
102
+ /**
103
+ * Returns current form values.
104
+ *
105
+ * @returns {Object}
106
+ */
107
+ getValues() {
108
+ return { ...this.state }
109
+ }
110
+
111
+ /**
112
+ * Validates the entire form.
113
+ *
114
+ * @returns {{isValid: boolean, errors: Object}} Validation result.
115
+ */
116
+ validate() {
117
+ const errors = {}
118
+ let isValid = true
119
+
120
+ this.fields.forEach((field) => {
121
+ const fieldValue = this.state[field.name]
122
+
123
+ // Required validation based on field definition or schema
124
+ if (field.required && (fieldValue === '' || fieldValue === null || fieldValue === undefined)) {
125
+ errors[field.name] = 'This field is required'
126
+ isValid = false
127
+ return
128
+ }
129
+
130
+ // Validation via schema (if provided) or field type
131
+ const { isValid: fieldValid, errors: fieldErrors } = this.validateField(field.name, fieldValue)
132
+
133
+ if (!fieldValid) {
134
+ Object.assign(errors, fieldErrors)
135
+ isValid = false
136
+ }
137
+ })
138
+
139
+ return { isValid, errors }
140
+ }
141
+
142
+ /**
143
+ * Validates a single field.
144
+ *
145
+ * @param {string} fieldName - Name of the field.
146
+ * @param {*} value - Value to validate.
147
+ * @returns {{isValid: boolean, errors: Object}}
148
+ */
149
+ validateField(fieldName, value) {
150
+ const field = this.getField(fieldName)
151
+ if (!field) return { isValid: false, errors: { [fieldName]: 'Field not found' } }
152
+
153
+ // Merge schema from UIForm.schema with field.type if schema does not specify a type
154
+ const schemaFromField = this.schema?.[fieldName] ? { ...this.schema[fieldName] } : {}
155
+ if (!schemaFromField.type && field.type) {
156
+ schemaFromField.type = field.type
157
+ }
158
+
159
+ return this.validateValue(fieldName, value, schemaFromField)
160
+ }
161
+
162
+ /**
163
+ * Validates a value against a schema.
164
+ *
165
+ * @param {string} fieldName - Name of the field.
166
+ * @param {*} value - Value to validate.
167
+ * @param {Object} schema - Validation schema.
168
+ * @returns {{isValid: boolean, errors: Object}}
169
+ */
170
+ validateValue(fieldName, value, schema) {
171
+ const errors = {}
172
+ let isValid = true
173
+
174
+ // Required rule (if defined in schema)
175
+ if (schema.required && (value === '' || value === null || value === undefined)) {
176
+ errors[fieldName] = 'This field is required'
177
+ isValid = false
178
+ }
179
+
180
+ if (schema.minLength && value && value.length < schema.minLength) {
181
+ errors[fieldName] = `Minimum length is ${schema.minLength}`
182
+ isValid = false
183
+ }
184
+
185
+ if (schema.maxLength && value && value.length > schema.maxLength) {
186
+ errors[fieldName] = `Maximum length is ${schema.maxLength}`
187
+ isValid = false
188
+ }
189
+
190
+ if (schema.pattern && value && !schema.pattern.test(value)) {
191
+ errors[fieldName] = schema.errorMessage || 'Invalid format'
192
+ isValid = false
193
+ }
194
+
195
+ // Type validation
196
+ if (schema.type) {
197
+ if (schema.type === 'email') {
198
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
199
+ if (value && !emailRegex.test(value)) {
200
+ errors[fieldName] = 'Invalid email format'
201
+ isValid = false
202
+ }
203
+ } else if (schema.type === 'number') {
204
+ if (value !== '' && isNaN(Number(value))) {
205
+ errors[fieldName] = 'Must be a number'
206
+ isValid = false
207
+ }
208
+ }
209
+ }
210
+
211
+ // Custom validator – can be a function or a string referencing a static validator
212
+ if (schema.validator) {
213
+ let result
214
+ if (typeof schema.validator === 'function') {
215
+ result = schema.validator(value)
216
+ } else if (typeof schema.validator === 'string') {
217
+ const fn = UIForm._validators[schema.validator]
218
+ if (!fn) {
219
+ throw new Error(`Validator "${schema.validator}" not registered`)
220
+ }
221
+ result = fn(value)
222
+ }
223
+ if (result !== true) {
224
+ errors[fieldName] = result || 'Invalid value'
225
+ isValid = false
226
+ }
227
+ }
228
+
229
+ return { isValid, errors }
230
+ }
231
+
232
+ /**
233
+ * Serialises the form to a plain JSON object.
234
+ *
235
+ * @returns {Object}
236
+ */
237
+ toJSON() {
238
+ return {
239
+ id: this.id,
240
+ type: this.type,
241
+ time: this.time.toISOString(),
242
+ title: this.title,
243
+ fields: this.fields.map(f => f.toJSON ? f.toJSON() : f),
244
+ state: this.state,
245
+ meta: this.meta
246
+ }
247
+ }
248
+
249
+ /**
250
+ * @param {*} input
251
+ * @returns {UIForm}
252
+ */
253
+ static from(input) {
254
+ if (input instanceof UIForm) return input
255
+ return new UIForm(input)
256
+ }
257
+
258
+ /**
259
+ * Auto‑generates form fields from a plain object.
260
+ *
261
+ * @param {Object} data - Example data object; its own enumerable keys become field names.
262
+ * @param {Object<string, Partial<import("./Input.js").default>>} [overrides={}]
263
+ * Optional per‑field overrides (e.g. type, required, label).
264
+ *
265
+ * @returns {UIForm} Form with Array of `FormInput` instances as form.fields
266
+ *
267
+ * Example:
268
+ * const fields = generateFieldsFromObject({ name: "", age: 0 }, {
269
+ * age: { type: FormInput.TYPES.NUMBER, required: true }
270
+ * })
271
+ */
272
+ static parse(data, overrides = {}) {
273
+ const fields = Object.keys(data).map((name) => {
274
+ const custom = overrides[name] || {}
275
+ const label = custom.label ?? name.charAt(0).toUpperCase() + name.slice(1)
276
+ return new FormInput({
277
+ name,
278
+ label,
279
+ type: custom.type ?? FormInput.TYPES.TEXT,
280
+ required: !!custom.required,
281
+ placeholder: custom.placeholder ?? "",
282
+ options: custom.options ?? [],
283
+ validator: custom.validator ?? undefined,
284
+ defaultValue: custom.defaultValue ?? "",
285
+ })
286
+ })
287
+ return new UIForm({ fields })
288
+ }
289
+ }