@inglorious/web 2.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.
package/src/form.js ADDED
@@ -0,0 +1,371 @@
1
+ import { clone, get, set } from "@inglorious/utils/data-structures/object.js"
2
+
3
+ /**
4
+ * @typedef {import('../types/form.js').FormEntity} FormEntity
5
+ * @typedef {import('../types/form.js').FormFieldChangePayload} FormFieldChangePayload
6
+ * @typedef {import('../types/form.js').FormFieldBlurPayload} FormFieldBlurPayload
7
+ *
8
+ * @typedef {object} FormValidatePayload
9
+ * @property {string|number} entityId - The ID of the target form entity.
10
+ * @property {(values: object) => import('../types/form.js').FormErrors<object>} validate - A function that validates the entire form's values and returns a complete error object.
11
+ *
12
+ * @typedef {object} FormValidateAsyncPayload
13
+ * @property {string|number} entityId - The ID of the target form entity.
14
+ * @property {(values: object) => Promise<import('../types/form.js').FormErrors<object>>} validate - An async function that validates the entire form's values.
15
+ *
16
+ * @typedef {object} FormValidationCompletePayload
17
+ * @property {string|number} entityId - The ID of the target form entity.
18
+ * @property {import('../types/form.js').FormErrors<object>} errors - The validation errors.
19
+ * @property {boolean} isValid - Whether the form is valid.
20
+ *
21
+ * @typedef {object} FormValidationErrorPayload
22
+ * @property {string|number} entityId - The ID of the target form entity.
23
+ * @property {string} error - The error message.
24
+ */
25
+
26
+ const NO_ITEMS_REMOVED = 0
27
+ const ONE_ITEM_REMOVED = 1
28
+
29
+ /**
30
+ * A type definition for managing form state within an entity-based system.
31
+ * It handles initialization, field changes, validation, and submission.
32
+ *
33
+ * @type {import('@inglorious/engine/types/type.js').Type}
34
+ */
35
+ export const form = {
36
+ /**
37
+ * Initializes the form entity by resetting it to its initial state.
38
+ * @param {FormEntity} entity - The form entity.
39
+ */
40
+ init(entity) {
41
+ resetForm(entity)
42
+
43
+ document.addEventListener("click", (e) => {
44
+ const button = e.target.closest("button")
45
+
46
+ if (!button || button.getAttribute("type") === "submit") return
47
+
48
+ e.preventDefault()
49
+ })
50
+
51
+ document.addEventListener("submit", (e) => {
52
+ const form = e.target.closest("form")
53
+
54
+ if (!form) return
55
+
56
+ e.preventDefault()
57
+ })
58
+ },
59
+
60
+ /**
61
+ * Resets the form entity when a 'create' event matches its ID.
62
+ * @param {FormEntity} entity - The form entity.
63
+ * @param {string|number} entityId - The ID from the create event.
64
+ */
65
+ create(entity) {
66
+ resetForm(entity)
67
+ },
68
+
69
+ /**
70
+ * Appends an item to a field array.
71
+ * @param {FormEntity} entity - The form entity.
72
+ * @param {Object} payload
73
+ * @param {string|number} payload.entityId - The form entity ID.
74
+ * @param {string} payload.path - Path to the array field (e.g., 'addresses').
75
+ * @param {*} payload.value - The value to append.
76
+ */
77
+ fieldArrayAppend(entity, { path, value }) {
78
+ const array = get(entity.values, path)
79
+ if (!Array.isArray(array)) {
80
+ console.warn(`Field at path '${path}' is not an array`)
81
+ return
82
+ }
83
+
84
+ array.push(value)
85
+
86
+ const errorsArray = get(entity.errors, path, [])
87
+ errorsArray.push(initMetadata(value))
88
+
89
+ const touchedArray = get(entity.touched, path, [])
90
+ touchedArray.push(initMetadata(value))
91
+
92
+ entity.isPristine = false
93
+ },
94
+
95
+ /**
96
+ * Removes an item from a field array by index.
97
+ * @param {FormEntity} entity - The form entity.
98
+ * @param {Object} payload
99
+ * @param {string|number} payload.entityId - The form entity ID.
100
+ * @param {string} payload.path - Path to the array field.
101
+ * @param {number} payload.index - The index to remove.
102
+ */
103
+ fieldArrayRemove(entity, { path, index }) {
104
+ const array = get(entity.values, path)
105
+ if (!Array.isArray(array)) {
106
+ console.warn(`Field at path '${path}' is not an array`)
107
+ return
108
+ }
109
+
110
+ array.splice(index, ONE_ITEM_REMOVED)
111
+
112
+ const errorsArray = get(entity.errors, path)
113
+ errorsArray.splice(index, ONE_ITEM_REMOVED)
114
+
115
+ const touchedArray = get(entity.touched, path)
116
+ touchedArray.splice(index, ONE_ITEM_REMOVED)
117
+
118
+ entity.isPristine = false
119
+ },
120
+
121
+ /**
122
+ * Inserts an item into a field array at a specific index.
123
+ * @param {FormEntity} entity - The form entity.
124
+ * @param {Object} payload
125
+ * @param {string|number} payload.entityId - The form entity ID.
126
+ * @param {string} payload.path - Path to the array field.
127
+ * @param {number} payload.index - The index to insert at.
128
+ * @param {*} payload.value - The value to insert.
129
+ */
130
+ fieldArrayInsert(entity, { path, index, value }) {
131
+ const array = get(entity.values, path)
132
+ if (!Array.isArray(array)) {
133
+ console.warn(`Field at path '${path}' is not an array`)
134
+ return
135
+ }
136
+
137
+ array.splice(index, NO_ITEMS_REMOVED, value)
138
+
139
+ const errorsArray = get(entity.errors, path)
140
+ errorsArray.splice(index, NO_ITEMS_REMOVED, initMetadata(value))
141
+
142
+ const touchedArray = get(entity.touched, path)
143
+ touchedArray.splice(index, NO_ITEMS_REMOVED, initMetadata(value))
144
+
145
+ entity.isPristine = false
146
+ },
147
+
148
+ /**
149
+ * Moves an item in a field array from one index to another.
150
+ * @param {FormEntity} entity - The form entity.
151
+ * @param {Object} payload
152
+ * @param {string|number} payload.entityId - The form entity ID.
153
+ * @param {string} payload.path - Path to the array field.
154
+ * @param {number} payload.fromIndex - The source index.
155
+ * @param {number} payload.toIndex - The destination index.
156
+ */
157
+ fieldArrayMove(entity, { path, fromIndex, toIndex }) {
158
+ const array = get(entity.values, path)
159
+ if (!Array.isArray(array)) {
160
+ console.warn(`Field at path '${path}' is not an array`)
161
+ return
162
+ }
163
+
164
+ const [item] = array.splice(fromIndex, ONE_ITEM_REMOVED)
165
+ array.splice(toIndex, NO_ITEMS_REMOVED, item)
166
+
167
+ const errorsArray = get(entity.errors, path)
168
+ const [errorItem] = errorsArray.splice(fromIndex, ONE_ITEM_REMOVED)
169
+ errorsArray.splice(toIndex, NO_ITEMS_REMOVED, errorItem)
170
+
171
+ const touchedArray = get(entity.touched, path)
172
+ const [touchedItem] = touchedArray.splice(fromIndex, ONE_ITEM_REMOVED)
173
+ touchedArray.splice(toIndex, NO_ITEMS_REMOVED, touchedItem)
174
+
175
+ entity.isPristine = false
176
+ },
177
+
178
+ /**
179
+ * Handles the blur event for a form field, marking it as touched and optionally validating it.
180
+ * @param {FormEntity} entity - The form entity.
181
+ * @param {FormFieldBlurPayload} payload - The event payload.
182
+ */
183
+ fieldBlur(entity, { path, validate }) {
184
+ set(entity.touched, path, true)
185
+
186
+ if (!validate) return
187
+
188
+ const error = validate(get(entity.values, path))
189
+ setFieldError(entity, path, error)
190
+ },
191
+
192
+ /**
193
+ * Handles a change in a form field's value and optionally validates it.
194
+ * @param {FormEntity} entity - The form entity.
195
+ * @param {FormFieldChangePayload} payload - The event payload.
196
+ */
197
+ fieldChange(entity, { path, value, validate }) {
198
+ setFieldValue(entity, path, value)
199
+
200
+ if (!validate) return
201
+
202
+ const error = validate(get(entity.values, path))
203
+ setFieldError(entity, path, error)
204
+ },
205
+
206
+ /**
207
+ * Resets the form to its initial state.
208
+ * @param {FormEntity} entity - The form entity.
209
+ * @param {string|number} entityId - The ID of the target form entity.
210
+ */
211
+ reset(entity) {
212
+ resetForm(entity)
213
+ },
214
+
215
+ /**
216
+ * Synchronously validates the entire form.
217
+ * @param {FormEntity} entity - The form entity.
218
+ * @param {FormValidatePayload} payload - The event payload.
219
+ */
220
+ validate(entity, { validate }) {
221
+ entity.errors = validate(entity.values)
222
+ entity.isValid = !hasErrors(entity.errors)
223
+ },
224
+
225
+ /**
226
+ * Asynchronously validates the entire form.
227
+ * Dispatches 'formValidationComplete' on success or 'formValidationError' on failure.
228
+ * @param {FormEntity} entity - The form entity.
229
+ * @param {FormValidateAsyncPayload} payload - The event payload.
230
+ * @param {Object} api - The API object for notifying events.
231
+ */
232
+ async validateAsync(entity, { validate }, api) {
233
+ try {
234
+ entity.isValidating = true
235
+ const errors = await validate(entity.values)
236
+ api.notify(`#${entity.id}:validationComplete`, {
237
+ errors,
238
+ isValid: !hasErrors(errors),
239
+ })
240
+ } catch (error) {
241
+ api.notify(`#${entity.id}:validationError`, {
242
+ error: error.message,
243
+ })
244
+ }
245
+ },
246
+
247
+ /**
248
+ * Handles the completion of async validation.
249
+ * @param {FormEntity} entity - The form entity.
250
+ * @param {FormValidationCompletePayload} payload - The event payload.
251
+ */
252
+ validationComplete(entity, { errors, isValid }) {
253
+ entity.isValidating = false
254
+ entity.errors = errors
255
+ entity.isValid = isValid
256
+ },
257
+
258
+ /**
259
+ * Handles validation errors from async validation.
260
+ * @param {FormEntity} entity - The form entity.
261
+ * @param {FormValidationErrorPayload} payload - The event payload.
262
+ */
263
+ validationError(entity, { error }) {
264
+ entity.isValidating = false
265
+ entity.submitError = error
266
+ },
267
+ }
268
+
269
+ /**
270
+ * Retrieves the validation error for a specific form field.
271
+ * @param {FormEntity} form - The form entity object.
272
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
273
+ * @returns {string|null|undefined} The error message for the field, or null/undefined if there is no error.
274
+ */
275
+ export function getFieldError(form, path) {
276
+ return get(form.errors, path)
277
+ }
278
+
279
+ /**
280
+ * Retrieves the value of a specific form field.
281
+ * @param {FormEntity} form - The form entity object.
282
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
283
+ * @param {*} [defaultValue] - An optional default value to return if the path does not exist.
284
+ * @returns {*} The value of the field, or the default value if not found.
285
+ */
286
+ export function getFieldValue(form, path, defaultValue) {
287
+ return get(form.values, path, defaultValue)
288
+ }
289
+
290
+ /**
291
+ * Checks if a specific form field has been touched (i.e., has received a blur event).
292
+ * @param {FormEntity} form - The form entity object.
293
+ * @param {string} path - The dot-separated path to the field (e.g., 'user.name').
294
+ * @returns {boolean|undefined} `true` if the field has been touched, otherwise `false` or `undefined`.
295
+ */
296
+ export function isFieldTouched(form, path) {
297
+ return get(form.touched, path)
298
+ }
299
+
300
+ // Private helper functions
301
+
302
+ function hasErrors(errors) {
303
+ if (errors === null || errors === undefined) {
304
+ return false
305
+ }
306
+
307
+ if (typeof errors !== "object") {
308
+ // Leaf value - check if it's truthy (error string)
309
+ return Boolean(errors)
310
+ }
311
+
312
+ if (Array.isArray(errors)) {
313
+ return errors.some((item) => hasErrors(item))
314
+ }
315
+
316
+ // Object - check all properties
317
+ return Object.values(errors).some((value) => hasErrors(value))
318
+ }
319
+
320
+ function initMetadata(value) {
321
+ if (Array.isArray(value)) {
322
+ return value.map((item) => initMetadata(item))
323
+ }
324
+
325
+ if (typeof value === "object" && value !== null) {
326
+ return Object.fromEntries(
327
+ Object.entries(value).map(([key, val]) => [key, initMetadata(val)]),
328
+ )
329
+ }
330
+
331
+ return null
332
+ }
333
+
334
+ function resetForm(form) {
335
+ form.values = clone(form.initialValues)
336
+ form.errors = initMetadata(form.initialValues)
337
+ form.touched = initMetadata(form.initialValues)
338
+
339
+ form.isValid = true
340
+ form.isPristine = true
341
+
342
+ form.isValidating = false
343
+ form.isSubmitting = false
344
+ }
345
+
346
+ function setFieldValue(form, path, value) {
347
+ set(form.values, path, value)
348
+ set(form.touched, path, true)
349
+
350
+ form.isPristine = false
351
+
352
+ // By clearing the error for this path, the form might become valid.
353
+ // We delegate to setFieldError(..., null) to correctly run the check.
354
+ setFieldError(form, path, null)
355
+ }
356
+
357
+ function setFieldError(form, path, error) {
358
+ // Ensure we set null if error is falsy, but not an empty string
359
+ const newError = error || null
360
+ set(form.errors, path, newError)
361
+
362
+ if (newError) {
363
+ // If we are adding an error, the form is definitely invalid.
364
+ // No need for an expensive check.
365
+ form.isValid = false
366
+ } else {
367
+ // If we are removing an error, the form MIGHT become valid.
368
+ // This is when we must perform the check.
369
+ form.isValid = !hasErrors(form.errors)
370
+ }
371
+ }
package/src/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { form, getFieldError, getFieldValue, isFieldTouched } from "./form.js"
2
+ export { list } from "./list.js"
3
+ export { mount } from "./mount.js"
4
+ export { router } from "./router.js"
5
+ export { createStore } from "@inglorious/store"
6
+ export { createDevtools } from "@inglorious/store/client/devtools.js"
7
+ export { createSelector } from "@inglorious/store/select.js"
8
+ export { html, render, svg } from "lit-html"
9
+ export { classMap } from "lit-html/directives/class-map.js"
10
+ export { repeat } from "lit-html/directives/repeat.js"
package/src/list.js ADDED
@@ -0,0 +1,105 @@
1
+ import { html } from "lit-html"
2
+ import { ref } from "lit-html/directives/ref.js"
3
+
4
+ const LIST_START = 0
5
+
6
+ export const list = {
7
+ init(entity) {
8
+ resetList(entity)
9
+ },
10
+
11
+ create(entity) {
12
+ resetList(entity)
13
+ },
14
+
15
+ render(entity, api) {
16
+ const { items, visibleRange, viewportHeight, itemHeight, estimatedHeight } =
17
+ entity
18
+ const types = api.getTypes()
19
+ const type = types[entity.type]
20
+
21
+ if (!items) {
22
+ console.warn(`list entity ${entity.id} needs 'items'`)
23
+ return html``
24
+ }
25
+
26
+ if (!type.renderItem) {
27
+ console.warn(`type ${entity.type} needs 'renderItem' method`)
28
+ return html``
29
+ }
30
+
31
+ const visibleItems = items.slice(visibleRange.start, visibleRange.end)
32
+ const height = itemHeight || estimatedHeight
33
+ const totalHeight = items.length * height
34
+
35
+ return html`
36
+ <div
37
+ style="height: ${viewportHeight}px; overflow: auto"
38
+ @scroll=${(e) => api.notify(`#${entity.id}:scroll`, e.target)}
39
+ ${ref((el) => {
40
+ if (el && !itemHeight) {
41
+ queueMicrotask(() => {
42
+ api.notify(`#${entity.id}:measureHeight`, el)
43
+ })
44
+ }
45
+ })}
46
+ >
47
+ <div style="height: ${totalHeight}px; position: relative">
48
+ ${visibleItems.map((item, idx) => {
49
+ const absoluteIndex = visibleRange.start + idx
50
+ const top = absoluteIndex * height
51
+
52
+ return html`
53
+ <div
54
+ style="position: absolute; top: ${top}px; width: 100%"
55
+ data-index=${absoluteIndex}
56
+ >
57
+ ${type.renderItem(item, absoluteIndex, api)}
58
+ </div>
59
+ `
60
+ })}
61
+ </div>
62
+ </div>
63
+ `
64
+ },
65
+
66
+ scroll(entity, containerEl) {
67
+ const scrollTop = containerEl.scrollTop
68
+ const { items, bufferSize, itemHeight, estimatedHeight, viewportHeight } =
69
+ entity
70
+ const height = itemHeight || estimatedHeight
71
+
72
+ const start = Math.max(
73
+ LIST_START,
74
+ Math.floor(scrollTop / height) - bufferSize,
75
+ )
76
+ const visibleCount = Math.ceil(viewportHeight / height)
77
+ const end = Math.min(start + visibleCount + bufferSize, items.length)
78
+
79
+ if (
80
+ entity.visibleRange.start === start &&
81
+ entity.visibleRange.end === end
82
+ ) {
83
+ return
84
+ }
85
+
86
+ entity.scrollTop = scrollTop
87
+ entity.visibleRange = { start, end }
88
+ },
89
+
90
+ measureHeight(entity, containerEl) {
91
+ const firstItem = containerEl.querySelector("[data-index]")
92
+ if (!firstItem) return
93
+
94
+ entity.itemHeight = firstItem.offsetHeight
95
+ },
96
+ }
97
+
98
+ function resetList(entity) {
99
+ entity.scrollTop = 0
100
+ entity.visibleRange ??= { start: 0, end: 20 }
101
+ entity.viewportHeight ??= 600
102
+ entity.bufferSize ??= 5
103
+ entity.itemHeight ??= null
104
+ entity.estimatedHeight ??= 50
105
+ }
package/src/mount.js ADDED
@@ -0,0 +1,49 @@
1
+ import { html, render } from "lit-html"
2
+
3
+ /**
4
+ * Mounts a lit-html template to the DOM and subscribes to a store for re-rendering.
5
+ * @param {import('@inglorious/store').Store} store - The application state store.
6
+ * @param {(api: import('../types/mount').Api) => import('lit-html').TemplateResult | null} renderFn - The root render function.
7
+ * @param {HTMLElement | DocumentFragment} element - The DOM element to mount the template to.
8
+ * @returns {() => void} An unsubscribe function
9
+ */
10
+ export function mount(store, renderFn, element) {
11
+ const api = {
12
+ ...store._api,
13
+
14
+ /** @param {string} id */
15
+ render(id, options = {}) {
16
+ const entity = api.getEntity(id)
17
+ const types = api.getTypes()
18
+
19
+ if (!entity) {
20
+ const { allowType } = options
21
+ if (!allowType) {
22
+ return ""
23
+ }
24
+
25
+ // No entity with this ID, try static type
26
+ const type = types[id]
27
+ if (!type?.render) {
28
+ console.warn(`No entity or type found: ${id}`)
29
+ return html`<div>Not found: ${id}</div>`
30
+ }
31
+ return type.render(api)
32
+ }
33
+
34
+ // Entity exists, render it
35
+ const type = types[entity.type]
36
+ if (!type?.render) {
37
+ console.warn(`No render function for type: ${entity.type}`)
38
+ return html`<div>No renderer for ${entity.type}</div>`
39
+ }
40
+
41
+ return type.render(entity, api)
42
+ },
43
+ }
44
+
45
+ const unsubscribe = store.subscribe(() => render(renderFn(api), element))
46
+ store.notify("init")
47
+
48
+ return unsubscribe
49
+ }