@inglorious/web 2.1.0 → 2.2.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/README.md CHANGED
@@ -404,9 +404,34 @@ Connects a store to a `lit-html` template and renders it into a DOM element. It
404
404
 
405
405
  ### The `api` Object
406
406
 
407
- The `renderFn` receives a powerful `api` object that contains all methods from the store's API (`getEntities`, `getEntity`, `notify`, etc.) plus a special `render(id)` method.
407
+ The `renderFn` receives a powerful `api` object that contains all methods from the store's API (`getEntities`, `getEntity`, `notify`, etc.) plus special methods for the web package.
408
408
 
409
- This `render(id)` 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.
409
+ **`api.render(id, options?)`**
410
+
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
+
413
+ **`api.select(selectorFn)`**
414
+
415
+ Selects a slice of the application state and returns a reactive object. 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 an object with a `value` property and an `unsubscribe` function.
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
+ - `{ value, unsubscribe }`: A reactive result object.
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 { value: user } = api.select((api) => api.getEntity("user-1"))
434
+ ```
410
435
 
411
436
  ### Re-exported `lit-html` Utilities
412
437
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
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",
@@ -38,19 +38,22 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "lit-html": "^3.3.1",
41
- "@inglorious/store": "7.1.0",
41
+ "@inglorious/store": "7.1.1",
42
42
  "@inglorious/utils": "3.7.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "prettier": "^3.6.2",
46
46
  "vite": "^7.1.3",
47
- "@inglorious/eslint-config": "1.0.1"
47
+ "vitest": "^4.0.15",
48
+ "@inglorious/eslint-config": "1.1.0"
48
49
  },
49
50
  "engines": {
50
51
  "node": ">= 22"
51
52
  },
52
53
  "scripts": {
53
54
  "format": "prettier --write '**/*.{js,jsx}'",
54
- "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
55
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
56
+ "test:watch": "vitest",
57
+ "test": "vitest run"
55
58
  }
56
59
  }
package/src/index.js CHANGED
@@ -11,3 +11,5 @@ export { choose } from "lit-html/directives/choose.js"
11
11
  export { classMap } from "lit-html/directives/class-map.js"
12
12
  export { ref } from "lit-html/directives/ref.js"
13
13
  export { repeat } from "lit-html/directives/repeat.js"
14
+ export { styleMap } from "lit-html/directives/style-map.js"
15
+ export { when } from "lit-html/directives/when.js"
package/src/list.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { html } from "lit-html"
2
2
  import { ref } from "lit-html/directives/ref.js"
3
+ import { styleMap } from "lit-html/directives/style-map.js"
3
4
 
4
5
  const LIST_START = 0
5
6
  const PRETTY_INDEX = 1
@@ -69,7 +70,7 @@ export const list = {
69
70
 
70
71
  return html`
71
72
  <div
72
- style="height: ${viewportHeight}px; overflow: auto"
73
+ style=${styleMap({ height: `${viewportHeight}px`, overflow: "auto" })}
73
74
  @scroll=${(e) => api.notify(`#${entity.id}:scroll`, e.target)}
74
75
  ${ref((el) => {
75
76
  if (el && !itemHeight) {
@@ -79,14 +80,23 @@ export const list = {
79
80
  }
80
81
  })}
81
82
  >
82
- <div style="height: ${totalHeight}px; position: relative">
83
+ <div
84
+ style=${styleMap({
85
+ height: `${totalHeight}px`,
86
+ position: "relative",
87
+ })}
88
+ >
83
89
  ${visibleItems.map((item, idx) => {
84
90
  const absoluteIndex = visibleRange.start + idx
85
91
  const top = absoluteIndex * height
86
92
 
87
93
  return html`
88
94
  <div
89
- style="position: absolute; top: ${top}px; width: 100%"
95
+ style=${styleMap({
96
+ position: "absolute",
97
+ top: `${top}px`,
98
+ width: "100%",
99
+ })}
90
100
  data-index=${absoluteIndex}
91
101
  >
92
102
  ${type.renderItem(item, absoluteIndex, api)}
package/src/mount.js CHANGED
@@ -8,41 +8,75 @@ import { html, render } from "lit-html"
8
8
  * @returns {() => void} An unsubscribe function
9
9
  */
10
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
-
18
- if (!entity) {
19
- const { allowType } = options
20
- if (!allowType) {
21
- return ""
22
- }
23
-
24
- // No entity with this ID, try static type
25
- const type = api.getType(id)
26
- if (!type?.render) {
27
- console.warn(`No entity or type found: ${id}`)
28
- return html`<div>Not found: ${id}</div>`
29
- }
30
- return type.render(api)
31
- }
11
+ const api = { ...store._api }
12
+ api.select = createReactiveSelector(api, store)
13
+ api.render = createRender(api)
32
14
 
33
- // Entity exists, render it
34
- const type = api.getType(entity.type)
35
- if (!type?.render) {
36
- console.warn(`No render function for type: ${entity.type}`)
37
- return html`<div>No renderer for ${entity.type}</div>`
15
+ const unsubscribe = store.subscribe(() => render(renderFn(api), element))
16
+ store.notify("init")
17
+
18
+ return unsubscribe
19
+ }
20
+
21
+ /**
22
+ * Creates a reactive selector function for the mount API.
23
+ * @param {import('../types/mount').Api} api - The mount API.
24
+ * @param {import('@inglorious/store').Store} store - The application state store.
25
+ * @returns {import('../types/mount').Api['select']} A `select` function that can be used to get a reactive slice of the state.
26
+ * @private
27
+ */
28
+ function createReactiveSelector(api, store) {
29
+ return function select(selectorFn) {
30
+ let current = selectorFn(api)
31
+
32
+ const unsubscribe = store.subscribe(() => {
33
+ const next = selectorFn(api)
34
+ if (next !== current) {
35
+ current = next
38
36
  }
37
+ })
39
38
 
40
- return type.render(entity, api)
41
- },
39
+ return {
40
+ get value() {
41
+ return current
42
+ },
43
+ unsubscribe,
44
+ }
42
45
  }
46
+ }
43
47
 
44
- const unsubscribe = store.subscribe(() => render(renderFn(api), element))
45
- store.notify("init")
48
+ /**
49
+ * Creates a render function for the mount API.
50
+ * @param {import('../types/mount').Api} api - The mount API.
51
+ * @returns {import('../types/mount').Api['render']} A `render` function that can render an entity or a type by its ID.
52
+ * @private
53
+ */
54
+ function createRender(api) {
55
+ return function (id, options = {}) {
56
+ const entity = api.getEntity(id)
46
57
 
47
- return unsubscribe
58
+ if (!entity) {
59
+ const { allowType } = options
60
+ if (!allowType) {
61
+ return ""
62
+ }
63
+
64
+ // No entity with this ID, try static type
65
+ const type = api.getType(id)
66
+ if (!type?.render) {
67
+ console.warn(`No entity or type found: ${id}`)
68
+ return html`<div>Not found: ${id}</div>`
69
+ }
70
+ return type.render(api)
71
+ }
72
+
73
+ // Entity exists, render it
74
+ const type = api.getType(entity.type)
75
+ if (!type?.render) {
76
+ console.warn(`No render function for type: ${entity.type}`)
77
+ return html`<div>No renderer for ${entity.type}</div>`
78
+ }
79
+
80
+ return type.render(entity, api)
81
+ }
48
82
  }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { createStore } from "@inglorious/store"
6
+ import { html } from "lit-html" // Only html is needed for templates, render is handled by mount
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
8
+
9
+ import { mount } from "./mount.js"
10
+
11
+ let rootElement
12
+
13
+ beforeEach(() => {
14
+ // Create a fresh DOM element for each test
15
+ rootElement = document.createElement("div")
16
+ document.body.appendChild(rootElement)
17
+ // Spy on console.warn to check for warnings without polluting test output
18
+ vi.spyOn(console, "warn").mockImplementation(() => {})
19
+ })
20
+
21
+ afterEach(() => {
22
+ // Clean up the DOM element after each test
23
+ document.body.removeChild(rootElement)
24
+ // Restore console.warn to its original implementation
25
+ vi.restoreAllMocks()
26
+ })
27
+
28
+ // Helper function to create a store with some common entities and types for testing
29
+ function setupStore(entitiesOverride = {}, typesOverride = {}) {
30
+ return createStore({
31
+ // Pass 'entities' as a top-level property to createStore
32
+ entities: {
33
+ "player-1": {
34
+ id: "player-1",
35
+ type: "player",
36
+ name: "Player One",
37
+ score: 0,
38
+ },
39
+ "enemy-1": { id: "enemy-1", type: "enemy", health: 100 },
40
+ ...entitiesOverride,
41
+ },
42
+ // Pass 'types' as a top-level property to createStore
43
+ types: {
44
+ player: {
45
+ render: (entity) =>
46
+ html`<span>Player: ${entity.name}, Score: ${entity.score}</span>`,
47
+ incrementScore: (entity) => {
48
+ entity.score++
49
+ },
50
+ },
51
+ enemy: {
52
+ render: (entity) => html`<span>Enemy: ${entity.health} HP</span>`,
53
+ takeDamage: (entity, amount) => {
54
+ entity.health -= amount
55
+ },
56
+ },
57
+ simpleType: {
58
+ render: () => html`<span>Simple Type Rendered</span>`,
59
+ },
60
+ typeWithoutRender: {
61
+ /* no render function */
62
+ },
63
+ ...typesOverride,
64
+ },
65
+ })
66
+ }
67
+
68
+ describe("mount", () => {
69
+ it("should render the initial state into the element", () => {
70
+ const store = setupStore()
71
+ const renderFn = (api) =>
72
+ html`<div>Hello, ${api.getEntity("player-1").name}!</div>`
73
+ mount(store, renderFn, rootElement)
74
+
75
+ expect(rootElement.textContent).toBe("Hello, Player One!")
76
+ })
77
+
78
+ it("should re-render when the store state changes", async () => {
79
+ const store = setupStore()
80
+ const renderFn = (api) =>
81
+ html`<div>Score: ${api.getEntity("player-1").score}</div>`
82
+ mount(store, renderFn, rootElement)
83
+
84
+ expect(rootElement.textContent).toBe("Score: 0")
85
+
86
+ store.notify("incrementScore", "player-1")
87
+ // lit-html renders asynchronously, so we need to wait for the next microtask
88
+ await Promise.resolve()
89
+
90
+ expect(rootElement.textContent).toBe("Score: 1")
91
+ })
92
+
93
+ it("should stop re-rendering after unsubscribe is called", async () => {
94
+ const store = setupStore()
95
+ const renderFn = (api) =>
96
+ html`<div>Score: ${api.getEntity("player-1").score}</div>`
97
+ const unsubscribe = mount(store, renderFn, rootElement)
98
+
99
+ expect(rootElement.textContent).toBe("Score: 0")
100
+
101
+ unsubscribe()
102
+ store.notify("incrementScore", "player-1")
103
+ await Promise.resolve() // Wait for potential re-render
104
+
105
+ // The score should still be 0 in the DOM because unsubscribe was called
106
+ expect(rootElement.textContent).toBe("Score: 0")
107
+ expect(rootElement.textContent).not.toBe("Score: 1")
108
+ })
109
+
110
+ describe("api.select", () => {
111
+ let capturedSelectResult // Used to capture the ReactiveSelectorResult instance across renders
112
+
113
+ it("should return the initial selected value", () => {
114
+ const store = setupStore()
115
+ const renderFn = (api) => {
116
+ capturedSelectResult = api.select(
117
+ (api) => api.getEntity("player-1").score,
118
+ )
119
+ return html`<div>Selected: ${capturedSelectResult.value}</div>`
120
+ }
121
+ mount(store, renderFn, rootElement)
122
+
123
+ expect(capturedSelectResult.value).toBe(0)
124
+ expect(rootElement.textContent).toBe("Selected: 0")
125
+ })
126
+
127
+ it("should update the selected value when the relevant state changes", async () => {
128
+ const store = setupStore()
129
+ const renderFn = (api) => {
130
+ capturedSelectResult = api.select(
131
+ (api) => api.getEntity("player-1").score,
132
+ )
133
+ return html`<div>Selected: ${capturedSelectResult.value}</div>`
134
+ }
135
+ mount(store, renderFn, rootElement)
136
+
137
+ expect(capturedSelectResult.value).toBe(0)
138
+
139
+ store.notify("incrementScore", "player-1")
140
+ await Promise.resolve() // Wait for store subscription to trigger and update `current` in select, and for lit-html to render
141
+
142
+ expect(capturedSelectResult.value).toBe(1)
143
+ expect(rootElement.textContent).toBe("Selected: 1")
144
+ })
145
+
146
+ it("should not update the selected value when unrelated state changes", async () => {
147
+ const store = setupStore()
148
+ const renderFn = (api) => {
149
+ capturedSelectResult = api.select(
150
+ (api) => api.getEntity("player-1").score,
151
+ )
152
+ return html`<div>Selected: ${capturedSelectResult.value}</div>`
153
+ }
154
+ mount(store, renderFn, rootElement)
155
+
156
+ expect(capturedSelectResult.value).toBe(0)
157
+
158
+ store.notify("takeDamage", "enemy-1", 10) // This changes enemy health, not player score
159
+ await Promise.resolve()
160
+
161
+ expect(capturedSelectResult.value).toBe(0) // Should remain 0
162
+ expect(rootElement.textContent).toBe("Selected: 0") // DOM also remains 0
163
+ })
164
+
165
+ it("should stop updating after api.select unsubscribe is called", async () => {
166
+ const store = setupStore()
167
+ let initialSelectResult
168
+ const renderFn = (api) => {
169
+ // Capture the select result only on the first render to test its specific unsubscribe
170
+ if (!initialSelectResult) {
171
+ initialSelectResult = api.select(
172
+ (api) => api.getEntity("player-1").score,
173
+ )
174
+ }
175
+ return html`<div>Selected: ${initialSelectResult.value}</div>`
176
+ }
177
+ mount(store, renderFn, rootElement)
178
+
179
+ expect(initialSelectResult.value).toBe(0)
180
+
181
+ initialSelectResult.unsubscribe() // Unsubscribe the reactive selector's internal listener
182
+ store.notify("incrementScore", "player-1")
183
+ await Promise.resolve() // Wait for store subscription to trigger and for lit-html to render
184
+
185
+ // The main mount subscription still triggers renderFn, but the `initialSelectResult.value`
186
+ // should *not* have updated because its internal listener was unsubscribed.
187
+ expect(initialSelectResult.value).toBe(0) // Should remain 0
188
+ expect(rootElement.textContent).toBe("Selected: 0")
189
+ expect(rootElement.textContent).not.toBe("Selected: 1")
190
+ })
191
+ })
192
+
193
+ describe("api.render", () => {
194
+ it("should render an entity by its ID", async () => {
195
+ const store = setupStore()
196
+ const renderFn = (api) => html`<div>${api.render("player-1")}</div>`
197
+ mount(store, renderFn, rootElement)
198
+ await Promise.resolve()
199
+
200
+ expect(rootElement.textContent).toBe("Player: Player One, Score: 0")
201
+ })
202
+
203
+ it("should render a type by its ID when allowType is true", async () => {
204
+ const store = setupStore()
205
+ const renderFn = (api) =>
206
+ html`<div>${api.render("simpleType", { allowType: true })}</div>`
207
+ mount(store, renderFn, rootElement)
208
+ await Promise.resolve()
209
+
210
+ expect(rootElement.textContent).toBe("Simple Type Rendered")
211
+ })
212
+
213
+ it("should return an empty string for a non-existent entity/type without allowType", async () => {
214
+ const store = setupStore()
215
+ const renderFn = (api) =>
216
+ html`<div>${api.render("non-existent-id")}</div>`
217
+ mount(store, renderFn, rootElement)
218
+ await Promise.resolve()
219
+
220
+ expect(rootElement.textContent).toBe("") // Empty div because api.render returns ""
221
+ expect(console.warn).not.toHaveBeenCalled() // No warning for non-existent entity without allowType
222
+ })
223
+
224
+ it("should return a fallback template and warn for a non-existent entity/type with allowType", async () => {
225
+ const store = setupStore()
226
+ const renderFn = (api) =>
227
+ html`<div>${api.render("non-existent-id", { allowType: true })}</div>`
228
+ mount(store, renderFn, rootElement)
229
+ await Promise.resolve()
230
+
231
+ expect(rootElement.textContent).toBe("Not found: non-existent-id")
232
+ expect(console.warn).toHaveBeenCalledWith(
233
+ "No entity or type found: non-existent-id",
234
+ )
235
+ })
236
+
237
+ it("should return a fallback template and warn for an entity whose type has no render function", async () => {
238
+ const store = setupStore({
239
+ "no-render-entity": {
240
+ id: "no-render-entity",
241
+ type: "typeWithoutRender",
242
+ },
243
+ })
244
+ const renderFn = (api) =>
245
+ html`<div>${api.render("no-render-entity")}</div>`
246
+ mount(store, renderFn, rootElement)
247
+ await Promise.resolve()
248
+
249
+ expect(rootElement.textContent).toBe("No renderer for typeWithoutRender")
250
+ expect(console.warn).toHaveBeenCalledWith(
251
+ "No render function for type: typeWithoutRender",
252
+ )
253
+ })
254
+ })
255
+ })
@@ -4,8 +4,7 @@ export const rangeFilter = {
4
4
  render(entity, column, api) {
5
5
  const filter = entity.filters[column.id] ?? {}
6
6
 
7
- return html`<div class="row">
8
- <input
7
+ return html`<input
9
8
  name=${`${column.id}Min`}
10
9
  type="number"
11
10
  placeholder=${column.filter.placeholder ?? "≥"}
@@ -36,7 +35,6 @@ export const rangeFilter = {
36
35
  })
37
36
  }}
38
37
  class="iw-table-cell-number"
39
- />
40
- </div>`
38
+ />`
41
39
  },
42
40
  }
@@ -292,8 +292,8 @@ function getDefaultColumnFilter(type) {
292
292
  }
293
293
 
294
294
  function getDefaultColumnWidth(filterType) {
295
- if (filterType === "number") return 100
296
- if (filterType === "range") return 200
295
+ if (filterType === "number") return 70
296
+ if (filterType === "range") return 100
297
297
  if (filterType === "select") return 70
298
298
  if (filterType === "date") return 120
299
299
  if (filterType === "time") return 120
@@ -1,4 +1,5 @@
1
1
  import { html } from "lit-html"
2
+ import { classMap } from "lit-html/directives/class-map.js"
2
3
  import { ref } from "lit-html/directives/ref.js"
3
4
 
4
5
  import { filters } from "./filters"
@@ -99,11 +100,11 @@ export const rendering = {
99
100
 
100
101
  return html`<div
101
102
  @click=${() => api.notify(`#${entity.id}:rowToggle`, rowId)}
102
- class="iw-table-row ${index % DIVISOR
103
- ? "iw-table-row-even"
104
- : "iw-table-row-odd"} ${entity.selection.includes(rowId)
105
- ? "iw-table-row-selected"
106
- : ""}"
103
+ class=${classMap({
104
+ "iw-table-row": true,
105
+ "iw-table-row-even": index % DIVISOR,
106
+ "iw-table-row-selected": entity.selection.includes(rowId),
107
+ })}
107
108
  >
108
109
  ${Object.values(row).map((value, index) =>
109
110
  type.renderCell(entity, value, index, api),
@@ -116,7 +117,12 @@ export const rendering = {
116
117
  const column = entity.columns[index]
117
118
 
118
119
  return html`<div
119
- class=${`iw-table-cell ${column.type === "number" ? "iw-table-cell-number" : ""} ${column.type === "date" ? "iw-table-cell-date" : ""} ${column.type === "boolean" ? "iw-table-cell-boolean" : ""}`}
120
+ class=${classMap({
121
+ "iw-table-cell": true,
122
+ "iw-table-cell-number": column.type === "number",
123
+ "iw-table-cell-date": column.type === "date",
124
+ "iw-table-cell-boolean": column.type === "boolean",
125
+ })}
120
126
  style=${getColumnStyle(column)}
121
127
  >
122
128
  ${type.renderValue(cell, column, api)}
@@ -182,7 +188,7 @@ export const rendering = {
182
188
  min="1"
183
189
  max=${pagination.totalPages}
184
190
  value=${pagination.page + PRETTY_PAGE}
185
- class=${`iw-table-page-input`}
191
+ class="iw-table-page-input"
186
192
  @input=${(event) =>
187
193
  api.notify(
188
194
  `#${entity.id}:pageChange`,
package/types/mount.d.ts CHANGED
@@ -1,13 +1,38 @@
1
1
  import type { TemplateResult } from "lit-html"
2
2
  import type { Store, Api as StoreApi } from "@inglorious/store"
3
3
 
4
+ /**
5
+ * The result of a reactive selector.
6
+ * @template T The type of the selected value.
7
+ */
8
+ export type ReactiveSelectorResult<T> = {
9
+ /** The current value of the selected state. */
10
+ readonly value: T
11
+ /** A function to stop listening for updates. */
12
+ unsubscribe: () => void
13
+ }
14
+
4
15
  export type Api = StoreApi & {
5
16
  /**
6
- * Renders a single entity by its ID.
7
- * @param id The ID of the entity to render.
8
- * @returns The rendered template or null.
17
+ * Selects a slice of the application state and returns a reactive object.
18
+ * The value will update whenever the selected part of the state changes.
19
+ *
20
+ * @template T The type of the selected state slice.
21
+ * @param selectorFn A function that takes the API and returns a slice of the state.
22
+ * @returns A reactive result object with the current value and an unsubscribe function.
23
+ */
24
+ select: <T>(selectorFn: (api: Api) => T) => ReactiveSelectorResult<T>
25
+
26
+ /**
27
+ * Renders an entity or a type component by its ID.
28
+ * @param id The ID of the entity or type to render.
29
+ * @param options Rendering options.
30
+ * @returns The rendered template or an empty string if not found.
9
31
  */
10
- render: (id: string) => TemplateResult | null
32
+ render: (
33
+ id: string,
34
+ options?: { allowType?: boolean },
35
+ ) => TemplateResult | string
11
36
  }
12
37
 
13
38
  /**