@inglorious/web 2.2.1 → 2.2.2
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/README.md +0 -24
- package/package.json +2 -2
- package/src/form.js +2 -2
- package/src/form.test.js +372 -0
- package/src/list.test.js +228 -0
- package/src/mount.js +13 -27
- package/src/router.test.js +158 -0
- package/src/table/rendering.js +7 -9
- package/src/table.test.js +372 -0
- package/types/mount.d.ts +0 -21
- package/src/mount.test.js +0 -255
package/README.md
CHANGED
|
@@ -410,30 +410,6 @@ The `renderFn` receives a powerful `api` object that contains all methods from t
|
|
|
410
410
|
|
|
411
411
|
This method is the cornerstone of entity-based rendering. It looks up an entity by its `id`, finds its corresponding type definition, and calls the `render(entity, api)` method on that type. This allows you to define rendering logic alongside an entity's other behaviors.
|
|
412
412
|
|
|
413
|
-
**`api.select(selectorFn)`**
|
|
414
|
-
|
|
415
|
-
Selects a slice of the application state and returns a reactive getter function. This is useful for creating components that only depend on a small part of the state, avoiding unnecessary re-renders.
|
|
416
|
-
|
|
417
|
-
The `selectorFn` receives the `api` and should return a value. The `select` method returns a getter function that you call to get the latest value. This function also has an `unsubscribe` property.
|
|
418
|
-
|
|
419
|
-
**Parameters:**
|
|
420
|
-
|
|
421
|
-
- `selectorFn(api)` (required): A function that takes the `api` and returns a slice of the state.
|
|
422
|
-
|
|
423
|
-
**Returns:**
|
|
424
|
-
|
|
425
|
-
- `() => T`: A reactive getter function. It also has an `unsubscribe` method attached.
|
|
426
|
-
|
|
427
|
-
**Example:**
|
|
428
|
-
|
|
429
|
-
While `mount` re-renders the entire application on any state change, `api.select` can be used inside a component to react only to specific changes. This is an advanced pattern for performance optimization.
|
|
430
|
-
|
|
431
|
-
```javascript
|
|
432
|
-
// Inside a component's render method
|
|
433
|
-
const getUser = api.select((api) => api.getEntity("user-1"))
|
|
434
|
-
const user = getUser() // Call the getter to get the value
|
|
435
|
-
```
|
|
436
|
-
|
|
437
413
|
### Re-exported `lit-html` Utilities
|
|
438
414
|
|
|
439
415
|
For convenience, `@inglorious/web` re-exports the most common utilities from `@inglorious/store` and `lit-html`, so you only need one import.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/web",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.2",
|
|
4
4
|
"description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
|
|
5
5
|
"author": "IceOnFire <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"prettier": "^3.6.2",
|
|
46
46
|
"vite": "^7.1.3",
|
|
47
47
|
"vitest": "^4.0.15",
|
|
48
|
-
"@inglorious/eslint-config": "1.1.
|
|
48
|
+
"@inglorious/eslint-config": "1.1.1"
|
|
49
49
|
},
|
|
50
50
|
"engines": {
|
|
51
51
|
"node": ">= 22"
|
package/src/form.js
CHANGED
|
@@ -291,10 +291,10 @@ export function getFieldValue(form, path, defaultValue) {
|
|
|
291
291
|
* Checks if a specific form field has been touched (i.e., has received a blur event).
|
|
292
292
|
* @param {FormEntity} form - The form entity object.
|
|
293
293
|
* @param {string} path - The dot-separated path to the field (e.g., 'user.name').
|
|
294
|
-
* @returns {boolean
|
|
294
|
+
* @returns {boolean} `true` if the field has been touched, otherwise `false`.
|
|
295
295
|
*/
|
|
296
296
|
export function isFieldTouched(form, path) {
|
|
297
|
-
return get(form.touched, path)
|
|
297
|
+
return Boolean(get(form.touched, path))
|
|
298
298
|
}
|
|
299
299
|
|
|
300
300
|
// Private helper functions
|
package/src/form.test.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
5
|
+
|
|
6
|
+
import { form, getFieldError, getFieldValue, isFieldTouched } from "./form.js"
|
|
7
|
+
|
|
8
|
+
describe("form", () => {
|
|
9
|
+
let entity
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
entity = {
|
|
13
|
+
id: "test-form",
|
|
14
|
+
initialValues: {
|
|
15
|
+
name: "John Doe",
|
|
16
|
+
contact: {
|
|
17
|
+
email: "john.doe@example.com",
|
|
18
|
+
},
|
|
19
|
+
tags: ["a", "b"],
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("init()", () => {
|
|
25
|
+
it("should reset the form to its initial state", () => {
|
|
26
|
+
// mess up the state first
|
|
27
|
+
entity.values = {}
|
|
28
|
+
entity.isPristine = false
|
|
29
|
+
|
|
30
|
+
form.init(entity)
|
|
31
|
+
|
|
32
|
+
expect(entity.values).toEqual(entity.initialValues)
|
|
33
|
+
expect(entity.values).not.toBe(entity.initialValues) // should be a clone
|
|
34
|
+
expect(entity.isPristine).toBe(true)
|
|
35
|
+
expect(entity.isValid).toBe(true)
|
|
36
|
+
expect(entity.errors).toEqual({
|
|
37
|
+
name: null,
|
|
38
|
+
contact: { email: null },
|
|
39
|
+
tags: [null, null],
|
|
40
|
+
})
|
|
41
|
+
expect(entity.touched).toEqual({
|
|
42
|
+
name: null,
|
|
43
|
+
contact: { email: null },
|
|
44
|
+
tags: [null, null],
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("should add global event listeners for click and submit", () => {
|
|
49
|
+
const spy = vi.spyOn(document, "addEventListener")
|
|
50
|
+
form.init(entity)
|
|
51
|
+
expect(spy).toHaveBeenCalledWith("click", expect.any(Function))
|
|
52
|
+
expect(spy).toHaveBeenCalledWith("submit", expect.any(Function))
|
|
53
|
+
spy.mockRestore()
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe("create()", () => {
|
|
58
|
+
it("should reset the form", () => {
|
|
59
|
+
entity.values = {}
|
|
60
|
+
form.create(entity)
|
|
61
|
+
expect(entity.values).toEqual(entity.initialValues)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe("reset()", () => {
|
|
66
|
+
it("should reset the form to its initial state", () => {
|
|
67
|
+
form.create(entity)
|
|
68
|
+
entity.values.name = "Jane Doe"
|
|
69
|
+
entity.isPristine = false
|
|
70
|
+
|
|
71
|
+
form.reset(entity)
|
|
72
|
+
|
|
73
|
+
expect(entity.values).toEqual(entity.initialValues)
|
|
74
|
+
expect(entity.isPristine).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe("fieldChange()", () => {
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
form.create(entity)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("should update a field's value, mark it as touched, and set form to dirty", () => {
|
|
84
|
+
form.fieldChange(entity, { path: "name", value: "Jane Doe" })
|
|
85
|
+
|
|
86
|
+
expect(entity.values.name).toBe("Jane Doe")
|
|
87
|
+
expect(entity.touched.name).toBe(true)
|
|
88
|
+
expect(entity.isPristine).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("should update a nested field's value", () => {
|
|
92
|
+
form.fieldChange(entity, {
|
|
93
|
+
path: "contact.email",
|
|
94
|
+
value: "jane.doe@example.com",
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
expect(entity.values.contact.email).toBe("jane.doe@example.com")
|
|
98
|
+
expect(entity.touched.contact.email).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it("should validate the field if a validate function is provided", () => {
|
|
102
|
+
const validate = (value) => (value.length < 10 ? "Too short" : null)
|
|
103
|
+
form.fieldChange(entity, { path: "name", value: "Jane", validate })
|
|
104
|
+
|
|
105
|
+
expect(entity.errors.name).toBe("Too short")
|
|
106
|
+
expect(entity.isValid).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it("should clear a field's error when it becomes valid", () => {
|
|
110
|
+
const validate = (value) => (value.length < 10 ? "Too short" : null)
|
|
111
|
+
form.fieldChange(entity, { path: "name", value: "Jane", validate })
|
|
112
|
+
expect(entity.isValid).toBe(false)
|
|
113
|
+
|
|
114
|
+
form.fieldChange(entity, {
|
|
115
|
+
path: "name",
|
|
116
|
+
value: "Jane Doe The Great",
|
|
117
|
+
validate,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
expect(entity.errors.name).toBe(null)
|
|
121
|
+
expect(entity.isValid).toBe(true)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe("fieldBlur()", () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
form.create(entity)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it("should mark a field as touched", () => {
|
|
131
|
+
form.fieldBlur(entity, { path: "name" })
|
|
132
|
+
expect(entity.touched.name).toBe(true)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("should validate the field if a validate function is provided", () => {
|
|
136
|
+
const validate = (value) =>
|
|
137
|
+
value === "John Doe" ? "Cannot be John" : null
|
|
138
|
+
form.fieldBlur(entity, { path: "name", validate })
|
|
139
|
+
|
|
140
|
+
expect(entity.errors.name).toBe("Cannot be John")
|
|
141
|
+
expect(entity.isValid).toBe(false)
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe("field array operations", () => {
|
|
146
|
+
beforeEach(() => {
|
|
147
|
+
form.create(entity)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("fieldArrayAppend: should append a value and metadata", () => {
|
|
151
|
+
form.fieldArrayAppend(entity, { path: "tags", value: "c" })
|
|
152
|
+
|
|
153
|
+
expect(entity.values.tags).toEqual(["a", "b", "c"])
|
|
154
|
+
expect(entity.errors.tags).toEqual([null, null, null])
|
|
155
|
+
expect(entity.touched.tags).toEqual([null, null, null])
|
|
156
|
+
expect(entity.isPristine).toBe(false)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("fieldArrayRemove: should remove a value and metadata at an index", () => {
|
|
160
|
+
form.fieldArrayRemove(entity, { path: "tags", index: 0 })
|
|
161
|
+
|
|
162
|
+
expect(entity.values.tags).toEqual(["b"])
|
|
163
|
+
expect(entity.errors.tags).toEqual([null])
|
|
164
|
+
expect(entity.touched.tags).toEqual([null])
|
|
165
|
+
expect(entity.isPristine).toBe(false)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("fieldArrayInsert: should insert a value and metadata at an index", () => {
|
|
169
|
+
form.fieldArrayInsert(entity, { path: "tags", index: 1, value: "c" })
|
|
170
|
+
|
|
171
|
+
expect(entity.values.tags).toEqual(["a", "c", "b"])
|
|
172
|
+
expect(entity.errors.tags).toEqual([null, null, null])
|
|
173
|
+
expect(entity.touched.tags).toEqual([null, null, null])
|
|
174
|
+
expect(entity.isPristine).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("fieldArrayMove: should move a value and metadata", () => {
|
|
178
|
+
entity.errors.tags[0] = "Error on A"
|
|
179
|
+
entity.touched.tags[0] = true
|
|
180
|
+
|
|
181
|
+
form.fieldArrayMove(entity, { path: "tags", fromIndex: 0, toIndex: 1 })
|
|
182
|
+
|
|
183
|
+
expect(entity.values.tags).toEqual(["b", "a"])
|
|
184
|
+
expect(entity.errors.tags).toEqual([null, "Error on A"])
|
|
185
|
+
expect(entity.touched.tags).toEqual([null, true])
|
|
186
|
+
expect(entity.isPristine).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("fieldArray operations should warn if path is not an array", () => {
|
|
190
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
191
|
+
|
|
192
|
+
form.fieldArrayAppend(entity, { path: "name", value: "c" })
|
|
193
|
+
expect(spy).toHaveBeenCalledWith("Field at path 'name' is not an array")
|
|
194
|
+
|
|
195
|
+
spy.mockRestore()
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe("validate()", () => {
|
|
200
|
+
beforeEach(() => {
|
|
201
|
+
form.create(entity)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it("should set errors and isValid based on the validation function", () => {
|
|
205
|
+
const validate = (values) => {
|
|
206
|
+
const errors = {}
|
|
207
|
+
if (values.name !== "Jane Doe") {
|
|
208
|
+
errors.name = "Name must be Jane Doe"
|
|
209
|
+
}
|
|
210
|
+
return errors
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
form.validate(entity, { validate })
|
|
214
|
+
|
|
215
|
+
expect(entity.errors.name).toBe("Name must be Jane Doe")
|
|
216
|
+
expect(entity.isValid).toBe(false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it("should set isValid to true if there are no errors", () => {
|
|
220
|
+
const validate = () => ({})
|
|
221
|
+
|
|
222
|
+
entity.isValid = false
|
|
223
|
+
form.validate(entity, { validate })
|
|
224
|
+
|
|
225
|
+
expect(entity.errors).toEqual({})
|
|
226
|
+
expect(entity.isValid).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("validateAsync()", () => {
|
|
231
|
+
let api
|
|
232
|
+
|
|
233
|
+
beforeEach(() => {
|
|
234
|
+
form.create(entity)
|
|
235
|
+
api = { notify: vi.fn() }
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("should set isValidating to true and call the validation function", async () => {
|
|
239
|
+
const validate = vi.fn().mockResolvedValue({})
|
|
240
|
+
await form.validateAsync(entity, { validate }, api)
|
|
241
|
+
expect(entity.isValidating).toBe(true)
|
|
242
|
+
expect(validate).toHaveBeenCalledWith(entity.values)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
it("should notify 'validationComplete' on successful validation", async () => {
|
|
246
|
+
const errors = { name: "Required" }
|
|
247
|
+
const validate = async () => errors
|
|
248
|
+
await form.validateAsync(entity, { validate }, api)
|
|
249
|
+
|
|
250
|
+
expect(api.notify).toHaveBeenCalledWith(
|
|
251
|
+
`#${entity.id}:validationComplete`,
|
|
252
|
+
{
|
|
253
|
+
errors,
|
|
254
|
+
isValid: false,
|
|
255
|
+
},
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("should notify 'validationError' on validation failure", async () => {
|
|
260
|
+
const error = new Error("Validation failed")
|
|
261
|
+
const validate = async () => {
|
|
262
|
+
throw error
|
|
263
|
+
}
|
|
264
|
+
await form.validateAsync(entity, { validate }, api)
|
|
265
|
+
|
|
266
|
+
expect(api.notify).toHaveBeenCalledWith(`#${entity.id}:validationError`, {
|
|
267
|
+
error: "Validation failed",
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
describe("validationComplete()", () => {
|
|
273
|
+
it("should update form state after async validation", () => {
|
|
274
|
+
form.create(entity)
|
|
275
|
+
entity.isValidating = true
|
|
276
|
+
const errors = { name: "Error" }
|
|
277
|
+
|
|
278
|
+
form.validationComplete(entity, { errors, isValid: false })
|
|
279
|
+
|
|
280
|
+
expect(entity.isValidating).toBe(false)
|
|
281
|
+
expect(entity.errors).toBe(errors)
|
|
282
|
+
expect(entity.isValid).toBe(false)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
describe("validationError()", () => {
|
|
287
|
+
it("should update form state after an async validation error", () => {
|
|
288
|
+
form.create(entity)
|
|
289
|
+
entity.isValidating = true
|
|
290
|
+
|
|
291
|
+
form.validationError(entity, { error: "Network Error" })
|
|
292
|
+
|
|
293
|
+
expect(entity.isValidating).toBe(false)
|
|
294
|
+
expect(entity.submitError).toBe("Network Error")
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe("form helpers", () => {
|
|
300
|
+
let testForm
|
|
301
|
+
|
|
302
|
+
beforeEach(() => {
|
|
303
|
+
testForm = {
|
|
304
|
+
values: {
|
|
305
|
+
user: { name: "Alex" },
|
|
306
|
+
tags: ["one", "two"],
|
|
307
|
+
},
|
|
308
|
+
errors: {
|
|
309
|
+
user: { name: "Too short" },
|
|
310
|
+
tags: [null, "Invalid tag"],
|
|
311
|
+
},
|
|
312
|
+
touched: {
|
|
313
|
+
user: { name: true },
|
|
314
|
+
tags: [true, false],
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe("getFieldValue()", () => {
|
|
320
|
+
it("should return the value of a field at a given path", () => {
|
|
321
|
+
expect(getFieldValue(testForm, "user.name")).toBe("Alex")
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("should return an array value", () => {
|
|
325
|
+
expect(getFieldValue(testForm, "tags")).toEqual(["one", "two"])
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it("should return a default value if path does not exist", () => {
|
|
329
|
+
expect(getFieldValue(testForm, "user.age", 30)).toBe(30)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it("should return undefined if path does not exist and no default is provided", () => {
|
|
333
|
+
expect(getFieldValue(testForm, "user.age")).toBeUndefined()
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
describe("getFieldError()", () => {
|
|
338
|
+
it("should return the error of a field at a given path", () => {
|
|
339
|
+
expect(getFieldError(testForm, "user.name")).toBe("Too short")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("should return the error of a field in an array", () => {
|
|
343
|
+
expect(getFieldError(testForm, "tags.1")).toBe("Invalid tag")
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
it("should return null for a valid field in an array", () => {
|
|
347
|
+
expect(getFieldError(testForm, "tags.0")).toBeNull()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("should return undefined if path does not exist", () => {
|
|
351
|
+
expect(getFieldError(testForm, "user.age")).toBe(undefined)
|
|
352
|
+
})
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
describe("isFieldTouched()", () => {
|
|
356
|
+
it("should return true if a field has been touched", () => {
|
|
357
|
+
expect(isFieldTouched(testForm, "user.name")).toBe(true)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("should return true for a touched field in an array", () => {
|
|
361
|
+
expect(isFieldTouched(testForm, "tags.0")).toBe(true)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it("should return false for an untouched field in an array", () => {
|
|
365
|
+
expect(isFieldTouched(testForm, "tags.1")).toBe(false)
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it("should return false if path does not exist", () => {
|
|
369
|
+
expect(isFieldTouched(testForm, "user.age")).toBe(false)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
})
|
package/src/list.test.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import { html } from "lit-html"
|
|
5
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
6
|
+
|
|
7
|
+
import { list } from "./list.js"
|
|
8
|
+
|
|
9
|
+
describe("list", () => {
|
|
10
|
+
let entity
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
entity = {
|
|
14
|
+
id: "test-list",
|
|
15
|
+
type: "test-item-type",
|
|
16
|
+
items: Array.from({ length: 100 }, (_, i) => ({
|
|
17
|
+
id: i,
|
|
18
|
+
name: `Item ${i}`,
|
|
19
|
+
})),
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe("init() and create()", () => {
|
|
24
|
+
it("should set default list properties on init", () => {
|
|
25
|
+
list.init(entity)
|
|
26
|
+
expect(entity.scrollTop).toBe(0)
|
|
27
|
+
expect(entity.visibleRange).toEqual({ start: 0, end: 20 })
|
|
28
|
+
expect(entity.viewportHeight).toBe(600)
|
|
29
|
+
expect(entity.bufferSize).toBe(5)
|
|
30
|
+
expect(entity.itemHeight).toBeNull()
|
|
31
|
+
expect(entity.estimatedHeight).toBe(50)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("should not overwrite existing properties on init (except scrollTop)", () => {
|
|
35
|
+
entity.viewportHeight = 800
|
|
36
|
+
entity.visibleRange = { start: 10, end: 30 }
|
|
37
|
+
|
|
38
|
+
list.init(entity)
|
|
39
|
+
|
|
40
|
+
expect(entity.viewportHeight).toBe(800)
|
|
41
|
+
expect(entity.visibleRange).toEqual({ start: 10, end: 30 })
|
|
42
|
+
expect(entity.scrollTop).toBe(0) // scrollTop is always reset
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("should reset the list on create", () => {
|
|
46
|
+
list.create(entity)
|
|
47
|
+
expect(entity.scrollTop).toBe(0)
|
|
48
|
+
expect(entity.visibleRange).toEqual({ start: 0, end: 20 })
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe("scroll()", () => {
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
list.init(entity)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it("should calculate visible range based on itemHeight", () => {
|
|
58
|
+
entity.itemHeight = 20
|
|
59
|
+
const containerEl = { scrollTop: 200 } // 10 items down
|
|
60
|
+
|
|
61
|
+
list.scroll(entity, containerEl)
|
|
62
|
+
|
|
63
|
+
// start = floor(200 / 20) - 5 = 10 - 5 = 5
|
|
64
|
+
// visibleCount = ceil(600 / 20) = 30
|
|
65
|
+
// end = min(5 + 30 + 5, 100) = 40
|
|
66
|
+
expect(entity.visibleRange).toEqual({ start: 5, end: 40 })
|
|
67
|
+
expect(entity.scrollTop).toBe(200)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("should calculate visible range based on estimatedHeight if itemHeight is null", () => {
|
|
71
|
+
entity.estimatedHeight = 50
|
|
72
|
+
const containerEl = { scrollTop: 500 } // 10 items down
|
|
73
|
+
|
|
74
|
+
list.scroll(entity, containerEl)
|
|
75
|
+
|
|
76
|
+
// start = floor(500 / 50) - 5 = 10 - 5 = 5
|
|
77
|
+
// visibleCount = ceil(600 / 50) = 12
|
|
78
|
+
// end = min(5 + 12 + 5, 100) = 22
|
|
79
|
+
expect(entity.visibleRange).toEqual({ start: 5, end: 22 })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("should not update if the visible range has not changed", () => {
|
|
83
|
+
entity.itemHeight = 20
|
|
84
|
+
entity.visibleRange = { start: 5, end: 40 }
|
|
85
|
+
const originalRange = { ...entity.visibleRange }
|
|
86
|
+
|
|
87
|
+
const containerEl = { scrollTop: 201 } // Still within the same range calculation
|
|
88
|
+
list.scroll(entity, containerEl)
|
|
89
|
+
|
|
90
|
+
expect(entity.visibleRange).toEqual(originalRange) // Should not have changed
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("should handle scrolling to the top", () => {
|
|
94
|
+
entity.itemHeight = 20
|
|
95
|
+
const containerEl = { scrollTop: 0 }
|
|
96
|
+
|
|
97
|
+
list.scroll(entity, containerEl)
|
|
98
|
+
|
|
99
|
+
// start = max(0, floor(0/20) - 5) = 0
|
|
100
|
+
// end = min(0 + 30 + 5, 100) = 35
|
|
101
|
+
expect(entity.visibleRange).toEqual({ start: 0, end: 35 })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it("should handle scrolling near the bottom", () => {
|
|
105
|
+
entity.itemHeight = 20
|
|
106
|
+
const containerEl = { scrollTop: 1900 } // 95 items down
|
|
107
|
+
|
|
108
|
+
list.scroll(entity, containerEl)
|
|
109
|
+
|
|
110
|
+
// start = floor(1900 / 20) - 5 = 95 - 5 = 90
|
|
111
|
+
// visibleCount = 30
|
|
112
|
+
// end = min(90 + 30 + 5, 100) = 100
|
|
113
|
+
expect(entity.visibleRange).toEqual({ start: 90, end: 100 })
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("mount()", () => {
|
|
118
|
+
it("should measure and set itemHeight and update visibleRange", () => {
|
|
119
|
+
list.init(entity)
|
|
120
|
+
const itemEl = document.createElement("div")
|
|
121
|
+
vi.spyOn(itemEl, "offsetHeight", "get").mockReturnValue(40)
|
|
122
|
+
|
|
123
|
+
const containerEl = {
|
|
124
|
+
querySelector: vi.fn().mockReturnValue(itemEl),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
list.mount(entity, containerEl)
|
|
128
|
+
|
|
129
|
+
expect(containerEl.querySelector).toHaveBeenCalledWith("[data-index]")
|
|
130
|
+
expect(entity.itemHeight).toBe(40)
|
|
131
|
+
// end = ceil(600 / 40) = 15
|
|
132
|
+
expect(entity.visibleRange).toEqual({ start: 0, end: 15 })
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it("should do nothing if no item element is found", () => {
|
|
136
|
+
list.init(entity)
|
|
137
|
+
const originalEntity = { ...entity }
|
|
138
|
+
const containerEl = {
|
|
139
|
+
querySelector: vi.fn().mockReturnValue(null),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
list.mount(entity, containerEl)
|
|
143
|
+
|
|
144
|
+
expect(entity).toEqual(originalEntity)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe("render()", () => {
|
|
149
|
+
let api
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
list.init(entity)
|
|
153
|
+
api = {
|
|
154
|
+
notify: vi.fn(),
|
|
155
|
+
getType: vi.fn().mockReturnValue({
|
|
156
|
+
renderItem: (item, index) => html`<div>${index}: ${item.name}</div>`,
|
|
157
|
+
}),
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("should return a lit-html TemplateResult", () => {
|
|
162
|
+
const result = list.render(entity, api)
|
|
163
|
+
// Duck-typing for TemplateResult, as it's not a public class
|
|
164
|
+
expect(result).toHaveProperty("strings")
|
|
165
|
+
expect(result).toHaveProperty("values")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("should warn and return empty if items are missing", () => {
|
|
169
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
170
|
+
delete entity.items
|
|
171
|
+
const result = list.render(entity, api)
|
|
172
|
+
expect(spy).toHaveBeenCalledWith(`list entity ${entity.id} needs 'items'`)
|
|
173
|
+
expect(result.strings.join("").trim()).toBe("")
|
|
174
|
+
spy.mockRestore()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it("should warn and return empty if type.renderItem is missing", () => {
|
|
178
|
+
const spy = vi.spyOn(console, "warn").mockImplementation(() => {})
|
|
179
|
+
api.getType.mockReturnValue({}) // No renderItem
|
|
180
|
+
const result = list.render(entity, api)
|
|
181
|
+
expect(spy).toHaveBeenCalledWith(
|
|
182
|
+
`type ${entity.type} needs 'renderItem' method`,
|
|
183
|
+
)
|
|
184
|
+
expect(result.strings.join("").trim()).toBe("")
|
|
185
|
+
spy.mockRestore()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("should render only the visible items", () => {
|
|
189
|
+
entity.itemHeight = 50
|
|
190
|
+
entity.visibleRange = { start: 10, end: 15 } // 5 visible items
|
|
191
|
+
|
|
192
|
+
const result = list.render(entity, api)
|
|
193
|
+
const renderedItems = result.values.find(Array.isArray)
|
|
194
|
+
|
|
195
|
+
expect(renderedItems).toHaveLength(5) // 5 visible items
|
|
196
|
+
// Check if the first rendered item is indeed item 10
|
|
197
|
+
const innerTemplate = renderedItems[0].values[2]
|
|
198
|
+
const renderedText = `${innerTemplate.values[0]}: ${innerTemplate.values[1]}`
|
|
199
|
+
expect(renderedText).toBe("10: Item 10")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("should calculate total height correctly", () => {
|
|
203
|
+
entity.itemHeight = 40
|
|
204
|
+
const result = list.render(entity, api)
|
|
205
|
+
// The styleMap for the inner div is at result.values[3].values[0]
|
|
206
|
+
const styleValue = result.values[3].values[0]
|
|
207
|
+
const expectedHeight = 100 * 40 // items.length * itemHeight
|
|
208
|
+
expect(styleValue.height).toBe(`${expectedHeight}px`)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
describe("renderItem()", () => {
|
|
213
|
+
it("should render a default item view", () => {
|
|
214
|
+
const item = { id: 1, value: "test" }
|
|
215
|
+
const result = list.renderItem(item, 5)
|
|
216
|
+
// Duck-typing for TemplateResult
|
|
217
|
+
expect(result).toHaveProperty("strings")
|
|
218
|
+
expect(result).toHaveProperty("values")
|
|
219
|
+
const fullText =
|
|
220
|
+
result.strings[0] +
|
|
221
|
+
result.values[0] +
|
|
222
|
+
result.strings[1] +
|
|
223
|
+
result.values[1] +
|
|
224
|
+
result.strings[2]
|
|
225
|
+
expect(fullText).toBe(`<div>6. ${JSON.stringify(item)}</div>`)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
})
|