@nan0web/ui 1.0.3 → 1.1.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 (43) hide show
  1. package/README.md +18 -20
  2. package/package.json +15 -16
  3. package/src/App/Command/DepsCommand.js +29 -0
  4. package/src/App/Core/UI.js +1 -50
  5. package/src/App/Core/Widget.js +4 -6
  6. package/src/App/User/Command/Message.js +2 -43
  7. package/src/App/User/Command/index.js +2 -8
  8. package/src/App/User/UserApp.js +31 -11
  9. package/src/README.md.js +33 -33
  10. package/src/StdIn.js +12 -13
  11. package/src/View/View.js +7 -7
  12. package/src/core/Form/Form.js +8 -6
  13. package/src/core/Form/Input.js +13 -13
  14. package/src/core/Form/Message.js +6 -5
  15. package/src/core/InputAdapter.js +2 -2
  16. package/src/core/Message/Message.js +109 -19
  17. package/src/core/Message/OutputMessage.js +7 -7
  18. package/src/core/Message/index.js +3 -4
  19. package/src/core/Stream.js +11 -10
  20. package/src/core/UiAdapter.js +216 -0
  21. package/src/core/index.js +10 -7
  22. package/src/index.js +4 -4
  23. package/types/App/Command/DepsCommand.d.ts +16 -0
  24. package/types/App/Core/UI.d.ts +2 -13
  25. package/types/App/Core/Widget.d.ts +6 -7
  26. package/types/App/User/Command/Message.d.ts +2 -29
  27. package/types/App/User/Command/index.d.ts +2 -4
  28. package/types/App/User/UserApp.d.ts +14 -7
  29. package/types/StdIn.d.ts +13 -13
  30. package/types/View/View.d.ts +6 -6
  31. package/types/core/Form/Form.d.ts +2 -5
  32. package/types/core/Form/Input.d.ts +4 -4
  33. package/types/core/Form/Message.d.ts +5 -10
  34. package/types/core/Intent.d.ts +91 -0
  35. package/types/core/Message/Message.d.ts +58 -15
  36. package/types/core/Message/OutputMessage.d.ts +3 -3
  37. package/types/core/Message/index.d.ts +3 -4
  38. package/types/core/Stream.d.ts +4 -4
  39. package/types/core/UiAdapter.d.ts +122 -0
  40. package/types/core/index.d.ts +6 -5
  41. package/types/index.d.ts +4 -4
  42. package/src/App/User/Command/Options.js +0 -48
  43. package/src/core/Message/InputMessage.js +0 -119
package/src/View/View.js CHANGED
@@ -3,13 +3,13 @@ import Frame, { FrameRenderMethod } from "../Frame/Frame.js"
3
3
  import Locale from "../Locale.js"
4
4
  import StdOut from "../StdOut.js"
5
5
  import StdIn from "../StdIn.js"
6
- import InputMessage from "../core/Message/InputMessage.js"
7
6
  import RenderOptions from "./RenderOptions.js"
7
+ import UiMessage from "../core/Message/Message.js"
8
8
 
9
9
  /**
10
10
  * @typedef {Object} ComponentFn
11
11
  * @property {string} name
12
- * @property {(input: InputMessage) => Promise<any>} ask
12
+ * @property {(input: UiMessage) => Promise<any>} ask
13
13
  * @property {Function} bind
14
14
  */
15
15
 
@@ -256,8 +256,8 @@ export default class View {
256
256
  }
257
257
 
258
258
  /**
259
- * @param {InputMessage} input
260
- * @returns {Promise<InputMessage | null>}
259
+ * @param {UiMessage} input
260
+ * @returns {Promise<UiMessage | null>}
261
261
  */
262
262
  async ask(input) {
263
263
  const name = input.constructor.name.replace(/Input$/, "")
@@ -268,9 +268,9 @@ export default class View {
268
268
  let result = null
269
269
  do {
270
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
271
+ result = /** @type {typeof UiMessage} */ (input.constructor).from(answer)
272
+ } while (!result.isValid && !result.head.cancelled)
273
+ return result.head.cancelled ? null : result
274
274
  }
275
275
 
276
276
  /**
@@ -1,6 +1,6 @@
1
+ import Message from "@nan0web/co"
1
2
  import FormMessage from "./Message.js"
2
3
  import FormInput from "./Input.js"
3
- import Message from "@nan0web/co"
4
4
 
5
5
  /**
6
6
  * Abstract form for data entry.
@@ -112,10 +112,10 @@ export default class UIForm extends FormMessage {
112
112
  /**
113
113
  * Validates the entire form.
114
114
  *
115
- * @returns {{isValid: boolean, errors: Object}} Validation result.
115
+ * @returns {Map<string, string>} Map of validation errors, empty if valid.
116
116
  */
117
117
  validate() {
118
- const errors = {}
118
+ const errors = new Map()
119
119
  let isValid = true
120
120
 
121
121
  this.fields.forEach((field) => {
@@ -123,7 +123,7 @@ export default class UIForm extends FormMessage {
123
123
 
124
124
  // Required validation based on field definition or schema
125
125
  if (field.required && (fieldValue === '' || fieldValue === null || fieldValue === undefined)) {
126
- errors[field.name] = 'This field is required'
126
+ errors.set(field.name, 'This field is required')
127
127
  isValid = false
128
128
  return
129
129
  }
@@ -132,12 +132,14 @@ export default class UIForm extends FormMessage {
132
132
  const { isValid: fieldValid, errors: fieldErrors } = this.validateField(field.name, fieldValue)
133
133
 
134
134
  if (!fieldValid) {
135
- Object.assign(errors, fieldErrors)
135
+ for (const [key, err] of Object.entries(fieldErrors)) {
136
+ errors.set(key, err)
137
+ }
136
138
  isValid = false
137
139
  }
138
140
  })
139
141
 
140
- return { isValid, errors }
142
+ return errors
141
143
  }
142
144
 
143
145
  /**
@@ -16,8 +16,8 @@
16
16
  * @property {string} type - Input type (text, email, number, select, etc.).
17
17
  * @property {boolean} required - Whether the field is required.
18
18
  * @property {string} placeholder - Placeholder text.
19
- * @property {Array<string>} options - Select options (if type is 'select').
20
- * @property {Function|null} validation - Custom validation function.
19
+ * @property {InputOptions} options - Select options (if type is 'select').
20
+ * @property {Function} validation - Custom validation function.
21
21
  * @property {*} defaultValue - Default value.
22
22
  */
23
23
  export default class FormInput {
@@ -27,7 +27,7 @@ export default class FormInput {
27
27
  /** @type {boolean} */ required = false
28
28
  /** @type {string} */ placeholder = ''
29
29
  /** @type {InputOptions} */ options = []
30
- /** @type {Function|null} */ validation = null
30
+ /** @type {Function} */ validation = () => true
31
31
  /** @type {*} */ defaultValue = null
32
32
 
33
33
  /**
@@ -39,7 +39,7 @@ export default class FormInput {
39
39
  NUMBER: 'number',
40
40
  SELECT: 'select',
41
41
  CHECKBOX: 'checkbox',
42
- TEXTAREA: 'textarea'
42
+ TEXTAREA: 'textarea',
43
43
  }
44
44
 
45
45
  /**
@@ -52,7 +52,7 @@ export default class FormInput {
52
52
  * @param {boolean} [props.required=false] - Is required.
53
53
  * @param {string} [props.placeholder=''] - Placeholder.
54
54
  * @param {InputOptions} [props.options=[]] - Select options or async function to retrieve data with the search and page.
55
- * @param {Function} [props.validation=null] - Custom validation.
55
+ * @param {Function} [props.validation] - Custom validation.
56
56
  * @param {*} [props.defaultValue=null] - Default value.
57
57
  */
58
58
  constructor(props) {
@@ -64,7 +64,7 @@ export default class FormInput {
64
64
  placeholder = this.placeholder,
65
65
  options = [],
66
66
  validation = this.validation,
67
- defaultValue = this.defaultValue
67
+ defaultValue = this.defaultValue,
68
68
  } = props
69
69
 
70
70
  if (!name) {
@@ -86,11 +86,11 @@ export default class FormInput {
86
86
  requireValidType() {
87
87
  if (!Object.values(FormInput.TYPES).includes(this.type)) {
88
88
  throw new TypeError([
89
- "FormInput.type is invalid!",
90
- ["Provided", this.type].join(": "),
91
- "Available types:",
92
- ...Object.values(FormInput.TYPES).map(t => ` - ${t}`)
93
- ].join("\n"))
89
+ 'FormInput.type is invalid!',
90
+ ['Provided', this.type].join(': '),
91
+ 'Available types:',
92
+ ...Object.values(FormInput.TYPES).map((t) => ` - ${t}`),
93
+ ].join('\n'))
94
94
  }
95
95
  }
96
96
 
@@ -107,7 +107,7 @@ export default class FormInput {
107
107
  required: this.required,
108
108
  placeholder: this.placeholder,
109
109
  options: this.options,
110
- defaultValue: this.defaultValue
110
+ defaultValue: this.defaultValue,
111
111
  }
112
112
  }
113
113
 
@@ -117,7 +117,7 @@ export default class FormInput {
117
117
  */
118
118
  static from(input) {
119
119
  if (input instanceof FormInput) return input
120
- if ("string" === typeof input) {
120
+ if (typeof input === 'string') {
121
121
  return new FormInput({ name: input, label: input })
122
122
  }
123
123
  return new FormInput(input)
@@ -1,12 +1,13 @@
1
- import InputMessage from "../Message/InputMessage.js"
1
+ import UiMessage from "../Message/Message.js"
2
2
 
3
3
  /**
4
- * FormMessage – specialized InputMessage for forms.
4
+ * FormMessage – specialized UiMessage for forms.
5
+ * It carries form-specific data and schema for validation.
5
6
  *
6
7
  * @class FormMessage
7
- * @extends InputMessage
8
+ * @extends UiMessage
8
9
  */
9
- export default class FormMessage extends InputMessage {
10
+ export default class FormMessage extends UiMessage {
10
11
  /**
11
12
  * Creates a FormMessage.
12
13
  *
@@ -80,4 +81,4 @@ export default class FormMessage extends InputMessage {
80
81
 
81
82
  return { isValid: Object.keys(errors).length === 0, errors }
82
83
  }
83
- }
84
+ }
@@ -1,6 +1,6 @@
1
1
  import Event from "@nan0web/event/oop"
2
- import InputMessage from "./Message/InputMessage.js"
3
2
  import CancelError from "./Error/CancelError.js"
3
+ import UiMessage from "./Message/Message.js"
4
4
 
5
5
  /**
6
6
  * Abstract input adapter for UI implementations.
@@ -21,7 +21,7 @@ export default class InputAdapter extends Event {
21
21
  */
22
22
  start() {
23
23
  this.emit('input',
24
- InputMessage.from({ value: "Adapter started" })
24
+ UiMessage.from({ body: "Adapter started" })
25
25
  )
26
26
  }
27
27
 
@@ -1,12 +1,36 @@
1
- import { Message as BaseMessage } from "@nan0web/co"
1
+ import { Message } from "@nan0web/co"
2
2
 
3
3
  /**
4
- * Base UI message class.
4
+ * @typedef {Object} MessageBodySchema
5
+ * @property {boolean} [required]
6
+ * @property {string} [help]
7
+ * @property {RegExp} [pattern]
8
+ * @property {string[]} [options]
9
+ * @property {*} [defaultValue]
10
+ * @property {Function} [validate]
11
+ */
12
+
13
+ /**
14
+ * Base message class for UI communications.
15
+ * A message holds structured data (body) defined by a static Body class.
16
+ * It can represent commands, forms, alerts, or any UI unit.
17
+ *
18
+ * @class UiMessage
19
+ * @extends Message
5
20
  *
6
- * @class UIMessage
7
- * @extends BaseMessage
21
+ * @example
22
+ * class UserLoginMessage extends UiMessage {
23
+ * static Body = class {
24
+ * static username = { required: true, help: "Enter username" }
25
+ * static password = { required: true, type: "password" }
26
+ * constructor({ username = "", password = "" }) {
27
+ * this.username = username
28
+ * this.password = password
29
+ * }
30
+ * }
31
+ * }
8
32
  */
9
- class UIMessage extends BaseMessage {
33
+ export default class UiMessage extends Message {
10
34
  static TYPES = {
11
35
  TEXT: 'text',
12
36
  FORM: 'form',
@@ -25,7 +49,7 @@ class UIMessage extends BaseMessage {
25
49
  id = ""
26
50
 
27
51
  /**
28
- * Creates a UIMessage.
52
+ * Creates a UiMessage.
29
53
  *
30
54
  * @param {Object} [input={}] - Message properties.
31
55
  */
@@ -45,14 +69,62 @@ class UIMessage extends BaseMessage {
45
69
  }
46
70
 
47
71
  /**
48
- * Creates a UIMessage instance from plain data.
72
+ * Checks whether the message contains any body content.
49
73
  *
50
- * @param {Object} data - Message data.
51
- * @returns {UIMessage}
74
+ * @returns {boolean}
52
75
  */
53
- static from(data) {
54
- if (data instanceof UIMessage) return data
55
- return new this(data)
76
+ get empty() {
77
+ return !this.body
78
+ }
79
+
80
+ /**
81
+ * Validates the message body against its schema.
82
+ *
83
+ * NOTE: The signature must exactly match `Message.validate` – it returns a
84
+ * `Map<string,string>` regardless of the generic type, otherwise TypeScript
85
+ * reports incompatibility with the base class.
86
+ *
87
+ * @param {any} [body=this.body] - Optional body to validate.
88
+ * @returns {Map<string,string>} Map of validation errors, empty if valid.
89
+ */
90
+ validate(body = this.body) {
91
+ /** @type {any} */
92
+ const Class = /** @type {typeof Message} */ (this.constructor).Body
93
+ const result = new Map()
94
+ const entries = /** @type {Array<[string, MessageBodySchema]>} */ (Object.entries(Class))
95
+
96
+ for (const [field, schema] of entries) {
97
+ const value = body[field]
98
+ const fn = schema?.validate
99
+ if ("function" === typeof fn) {
100
+ const ok = fn.apply(body, [value])
101
+ if (ok !== true) {
102
+ result.set(field, String(ok))
103
+ continue
104
+ }
105
+ }
106
+ const required = schema?.required ?? false
107
+ if (required && !value) {
108
+ result.set(field, "Required")
109
+ continue
110
+ }
111
+ if (schema?.pattern && schema.pattern instanceof RegExp) {
112
+ if (!schema.pattern.test(value)) {
113
+ result.set(field, `Does not match pattern: ${schema.pattern}`)
114
+ continue
115
+ }
116
+ }
117
+ if (schema?.options) {
118
+ if (!Array.isArray(schema.options)) {
119
+ throw new Error("Schema options must be an array of possible values")
120
+ }
121
+ if (!schema.options.includes(value)) {
122
+ result.set(field, "Enumeration must have one value")
123
+ continue
124
+ }
125
+ }
126
+ }
127
+ return result
56
128
  }
57
129
 
58
130
  /**
@@ -61,17 +133,35 @@ class UIMessage extends BaseMessage {
61
133
  * @returns {boolean}
62
134
  */
63
135
  isValidType() {
64
- return Object.values(UIMessage.TYPES).includes(this.type)
136
+ return Object.values(UiMessage.TYPES).includes(this.type)
65
137
  }
66
138
 
67
139
  /**
68
- * Checks whether the message contains any body content.
140
+ * Creates a UiMessage instance from plain data.
69
141
  *
70
- * @returns {boolean}
142
+ * @param {Object} data - Message data.
143
+ * @returns {UiMessage}
71
144
  */
72
- isEmpty() {
73
- return !this.body || this.body.length === 0
145
+ static from(data) {
146
+ if (data instanceof UiMessage) return data
147
+ return new this(data)
74
148
  }
75
- }
76
149
 
77
- export default UIMessage
150
+ /**
151
+ * Initializes body from input using static Body schema.
152
+ *
153
+ * @param {Object} input - Input object.
154
+ * @param {Function} BodyClass - Static body class with defaults and schema.
155
+ * @returns {Object} Parsed body.
156
+ */
157
+ static parseBody(input = {}, BodyClass) {
158
+ const result = {}
159
+ const entries = /** @type {Array<[string, MessageBodySchema]>} */ (Object.entries(BodyClass))
160
+
161
+ for (const [field, schema] of entries) {
162
+ const { defaultValue = undefined, ...schemaProps } = schema
163
+ result[field] = input[field] ?? defaultValue
164
+ }
165
+ return result
166
+ }
167
+ }
@@ -1,12 +1,12 @@
1
- import UIMessage from "./Message.js"
1
+ import UiMessage from "./Message.js"
2
2
 
3
3
  /**
4
4
  * OutputMessage – message sent from the system to the UI.
5
5
  *
6
6
  * @class OutputMessage
7
- * @extends UIMessage
7
+ * @extends UiMessage
8
8
  */
9
- export default class OutputMessage extends UIMessage {
9
+ export default class OutputMessage extends UiMessage {
10
10
  static PRIORITY = {
11
11
  LOW: 0,
12
12
  NORMAL: 1,
@@ -52,9 +52,9 @@ export default class OutputMessage extends UIMessage {
52
52
  this.priority = Number(priority)
53
53
 
54
54
  if (!this.type && this.error) {
55
- this.type = UIMessage.TYPES.ERROR
55
+ this.type = UiMessage.TYPES.ERROR
56
56
  } else if (!this.type) {
57
- this.type = UIMessage.TYPES.INFO
57
+ this.type = UiMessage.TYPES.INFO
58
58
  }
59
59
  }
60
60
 
@@ -72,11 +72,11 @@ export default class OutputMessage extends UIMessage {
72
72
  }
73
73
  /** @returns {boolean} */
74
74
  get isError() {
75
- return this.error !== null || this.type === UIMessage.TYPES.ERROR
75
+ return this.error !== null || this.type === UiMessage.TYPES.ERROR
76
76
  }
77
77
  /** @returns {boolean} */
78
78
  get isInfo() {
79
- return this.type === UIMessage.TYPES.INFO || this.type === UIMessage.TYPES.SUCCESS
79
+ return this.type === UiMessage.TYPES.INFO || this.type === UiMessage.TYPES.SUCCESS
80
80
  }
81
81
 
82
82
  /**
@@ -1,7 +1,6 @@
1
- import UIMessage from "./Message.js"
2
- import InputMessage from "./InputMessage.js"
1
+ import UiMessage from "./Message.js"
3
2
  import OutputMessage from "./OutputMessage.js"
4
3
 
5
- export { UIMessage, InputMessage, OutputMessage }
4
+ export { UiMessage, OutputMessage }
6
5
 
7
- export default UIMessage
6
+ export default UiMessage
@@ -1,7 +1,7 @@
1
1
  import StreamEntry from "./StreamEntry.js"
2
2
 
3
3
  /**
4
- * Agnostic UI stream for processing progress.
4
+ * Agnostic UI stream for processing progress using async generators.
5
5
  *
6
6
  * @class UIStream
7
7
  */
@@ -16,6 +16,9 @@ export default class UIStream {
16
16
  static createProcessor(signal, processorFn) {
17
17
  return async function* () {
18
18
  try {
19
+ if (signal.aborted) {
20
+ throw new DOMException('Aborted', 'AbortError')
21
+ }
19
22
  const result = await processorFn()
20
23
  yield result
21
24
  } catch (/** @type {any} */ error) {
@@ -29,17 +32,17 @@ export default class UIStream {
29
32
  }
30
33
 
31
34
  /**
32
- * Runs a generator with progress callbacks and abort handling.
35
+ * Runs an async generator with progress callbacks and abort handling.
33
36
  *
34
37
  * @param {AbortSignal} signal - Abort signal.
35
- * @param {Function} generator - Function returning an async iterator.
38
+ * @param {() => AsyncGenerator<StreamEntry>} generatorFn - Function that returns an async generator.
36
39
  * @param {Function} [onProgress] - Called with (progress, item).
37
40
  * @param {Function} [onError] - Called with (errorMessage, item).
38
41
  * @param {Function} [onComplete] - Called with (item) when done.
39
42
  * @returns {Promise<void>}
40
43
  */
41
- static async process(signal, generator, onProgress, onError, onComplete) {
42
- const iter = generator()
44
+ static async process(signal, generatorFn, onProgress, onError, onComplete) {
45
+ const iter = generatorFn()
43
46
 
44
47
  try {
45
48
  for await (const item of iter) {
@@ -47,13 +50,11 @@ export default class UIStream {
47
50
  throw new DOMException('Aborted', 'AbortError')
48
51
  }
49
52
 
50
- if (item.progress !== undefined) {
51
- onProgress?.(item.progress, item)
52
- } else if (item.error) {
53
- onError?.(item.error, item)
54
- } else if (item.done) {
53
+ if (item.done) {
55
54
  onComplete?.(item)
56
55
  break
56
+ } else if (item.error) {
57
+ onError?.(item.error, item)
57
58
  } else {
58
59
  // Intermediate results
59
60
  onProgress?.(null, item)