@inglorious/web 2.0.0 → 2.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.
package/README.md CHANGED
@@ -164,6 +164,77 @@ mount(store, renderApp, document.getElementById("root"))
164
164
 
165
165
  The router automatically intercepts clicks on local `<a>` tags and handles browser back/forward events, keeping your UI in sync with the URL.
166
166
 
167
+ ### 3. Programmatic Navigation
168
+
169
+ To navigate from your JavaScript code, dispatch a `navigate` event.
170
+
171
+ ```javascript
172
+ api.notify("navigate", "/users/456")
173
+
174
+ // Or navigate back in history
175
+ api.notify("navigate", -1)
176
+ ```
177
+
178
+ ---
179
+
180
+ ## Table
181
+
182
+ `@inglorious/web` includes a `table` type for displaying data in a tabular format. It's designed to be flexible and customizable.
183
+
184
+ ### 1. Add the `table` type
185
+
186
+ To use it, import the `table` type and its CSS, then create an entity for your table. You must define the `data` to be displayed and can optionally provide `columns` definitions.
187
+
188
+ ```javascript
189
+ // In your entity definition file
190
+ import { table } from "@inglorious/web"
191
+
192
+ // Import base styles and a theme. You can create your own theme.
193
+ import "@inglorious/web/table/base.css"
194
+ import "@inglorious/web/table/theme.css"
195
+
196
+ export default {
197
+ ...table,
198
+ data: [
199
+ { id: 1, name: "Product A", price: 100 },
200
+ { id: 2, name: "Product B", price: 150 },
201
+ ],
202
+ columns: [
203
+ { id: "id", label: "ID" },
204
+ { id: "name", label: "Product Name" },
205
+ { id: "price", label: "Price" },
206
+ ],
207
+ }
208
+ ```
209
+
210
+ ### 2. Custom Rendering
211
+
212
+ You can customize how data is rendered in the table cells by overriding the `renderValue` method. This is useful for formatting values or displaying custom content.
213
+
214
+ The example below from `examples/apps/web-table/src/product-table/product-table.js` shows how to format values based on a `formatter` property in the column definition.
215
+
216
+ ```javascript
217
+ import { table } from "@inglorious/web"
218
+ import { format } from "date-fns"
219
+
220
+ const formatters = {
221
+ isAvailable: (val) => (val ? "✔️" : "❌"),
222
+ createdAt: (val) => format(val, "dd/MM/yyyy HH:mm"),
223
+ }
224
+
225
+ export const productTable = {
226
+ ...table,
227
+
228
+ renderValue(value, column) {
229
+ return formatters[column.formatter]?.(value) ?? value
230
+ },
231
+ }
232
+ ```
233
+
234
+ ### 3. Theming
235
+
236
+ The table comes with a base stylesheet (`@inglorious/web/table/base.css`) and a default theme (`@inglorious/web/table/theme.css`). You can create your own theme by creating a new CSS file and styling the table elements to match your application's design.
237
+
167
238
  ---
168
239
 
169
240
  ## Forms
@@ -315,17 +386,6 @@ const store = createStore({ types, entities })
315
386
 
316
387
  See `src/list.js` in the package for the implementation details and the `examples/apps/web-list` demo for a complete working example. In the demo the `productList` type extends the `list` type and provides `renderItem(item, index)` to render each visible item — see `examples/apps/web-list/src/product-list/product-list.js`.
317
388
 
318
- ### 3. Programmatic Navigation
319
-
320
- To navigate from your JavaScript code, dispatch a `navigate` event.
321
-
322
- ```javascript
323
- api.notify("navigate", "/users/456")
324
-
325
- // Or navigate back in history
326
- api.notify("navigate", -1)
327
- ```
328
-
329
389
  ## API Reference
330
390
 
331
391
  **`mount(store, renderFn, element)`**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "2.0.0",
3
+ "version": "2.1.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",
@@ -25,7 +25,9 @@
25
25
  ".": {
26
26
  "types": "./types/index.d.ts",
27
27
  "import": "./src/index.js"
28
- }
28
+ },
29
+ "./table/base.css": "./src/table/base.css",
30
+ "./table/theme.css": "./src/table/theme.css"
29
31
  },
30
32
  "files": [
31
33
  "src",
@@ -36,7 +38,7 @@
36
38
  },
37
39
  "dependencies": {
38
40
  "lit-html": "^3.3.1",
39
- "@inglorious/store": "7.0.0",
41
+ "@inglorious/store": "7.1.0",
40
42
  "@inglorious/utils": "3.7.0"
41
43
  },
42
44
  "devDependencies": {
package/src/index.js CHANGED
@@ -2,9 +2,12 @@ export { form, getFieldError, getFieldValue, isFieldTouched } from "./form.js"
2
2
  export { list } from "./list.js"
3
3
  export { mount } from "./mount.js"
4
4
  export { router } from "./router.js"
5
+ export { table } from "./table.js"
5
6
  export { createStore } from "@inglorious/store"
6
7
  export { createDevtools } from "@inglorious/store/client/devtools.js"
7
8
  export { createSelector } from "@inglorious/store/select.js"
8
9
  export { html, render, svg } from "lit-html"
10
+ export { choose } from "lit-html/directives/choose.js"
9
11
  export { classMap } from "lit-html/directives/class-map.js"
12
+ export { ref } from "lit-html/directives/ref.js"
10
13
  export { repeat } from "lit-html/directives/repeat.js"
package/src/list.js CHANGED
@@ -2,6 +2,7 @@ import { html } from "lit-html"
2
2
  import { ref } from "lit-html/directives/ref.js"
3
3
 
4
4
  const LIST_START = 0
5
+ const PRETTY_INDEX = 1
5
6
 
6
7
  export const list = {
7
8
  init(entity) {
@@ -12,11 +13,45 @@ export const list = {
12
13
  resetList(entity)
13
14
  },
14
15
 
16
+ scroll(entity, containerEl) {
17
+ const scrollTop = containerEl.scrollTop
18
+ const { items, bufferSize, itemHeight, estimatedHeight, viewportHeight } =
19
+ entity
20
+ const height = itemHeight || estimatedHeight
21
+
22
+ const start = Math.max(
23
+ LIST_START,
24
+ Math.floor(scrollTop / height) - bufferSize,
25
+ )
26
+ const visibleCount = Math.ceil(viewportHeight / height)
27
+ const end = Math.min(start + visibleCount + bufferSize, items.length)
28
+
29
+ if (
30
+ entity.visibleRange.start === start &&
31
+ entity.visibleRange.end === end
32
+ ) {
33
+ return
34
+ }
35
+
36
+ entity.scrollTop = scrollTop
37
+ entity.visibleRange = { start, end }
38
+ },
39
+
40
+ mount(entity, containerEl) {
41
+ const firstItem = containerEl.querySelector("[data-index]")
42
+ if (!firstItem) return
43
+
44
+ entity.itemHeight = firstItem.offsetHeight
45
+ entity.visibleRange = {
46
+ start: 0,
47
+ end: Math.ceil(entity.viewportHeight / entity.itemHeight),
48
+ }
49
+ },
50
+
15
51
  render(entity, api) {
16
52
  const { items, visibleRange, viewportHeight, itemHeight, estimatedHeight } =
17
53
  entity
18
- const types = api.getTypes()
19
- const type = types[entity.type]
54
+ const type = api.getType(entity.type)
20
55
 
21
56
  if (!items) {
22
57
  console.warn(`list entity ${entity.id} needs 'items'`)
@@ -39,7 +74,7 @@ export const list = {
39
74
  ${ref((el) => {
40
75
  if (el && !itemHeight) {
41
76
  queueMicrotask(() => {
42
- api.notify(`#${entity.id}:measureHeight`, el)
77
+ api.notify(`#${entity.id}:mount`, el)
43
78
  })
44
79
  }
45
80
  })}
@@ -63,35 +98,8 @@ export const list = {
63
98
  `
64
99
  },
65
100
 
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
101
+ renderItem(item, index) {
102
+ return html`<div>${index + PRETTY_INDEX}. ${JSON.stringify(item)}</div>`
95
103
  },
96
104
  }
97
105
 
package/src/mount.js CHANGED
@@ -14,7 +14,6 @@ export function mount(store, renderFn, element) {
14
14
  /** @param {string} id */
15
15
  render(id, options = {}) {
16
16
  const entity = api.getEntity(id)
17
- const types = api.getTypes()
18
17
 
19
18
  if (!entity) {
20
19
  const { allowType } = options
@@ -23,7 +22,7 @@ export function mount(store, renderFn, element) {
23
22
  }
24
23
 
25
24
  // No entity with this ID, try static type
26
- const type = types[id]
25
+ const type = api.getType(id)
27
26
  if (!type?.render) {
28
27
  console.warn(`No entity or type found: ${id}`)
29
28
  return html`<div>Not found: ${id}</div>`
@@ -32,7 +31,7 @@ export function mount(store, renderFn, element) {
32
31
  }
33
32
 
34
33
  // Entity exists, render it
35
- const type = types[entity.type]
34
+ const type = api.getType(entity.type)
36
35
  if (!type?.render) {
37
36
  console.warn(`No render function for type: ${entity.type}`)
38
37
  return html`<div>No renderer for ${entity.type}</div>`
@@ -0,0 +1,34 @@
1
+ .iw-table {
2
+ user-select: none;
3
+
4
+ .iw-table-header {
5
+ display: flex;
6
+ flex-direction: column;
7
+ }
8
+
9
+ .iw-table-header-row {
10
+ display: flex;
11
+ }
12
+
13
+ .iw-table-header-column {
14
+ display: flex;
15
+ flex-direction: column;
16
+ }
17
+
18
+ .iw-table-filter-input,
19
+ .iw-table-filter-select {
20
+ width: 100%;
21
+ }
22
+
23
+ .iw-table-searchbar {
24
+ width: 100%;
25
+ }
26
+
27
+ .iw-table-row {
28
+ display: flex;
29
+ }
30
+
31
+ .iw-table-footer-row {
32
+ display: flex;
33
+ }
34
+ }
@@ -0,0 +1,42 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ const INPUT_TYPE = {
4
+ date: "date",
5
+ time: "time",
6
+ datetime: "datetime-local",
7
+ }
8
+
9
+ export const dateFilter = {
10
+ render(entity, column, api) {
11
+ const filter = entity.filters[column.id] ?? {}
12
+
13
+ return html`<input
14
+ name=${`${column.id}Min`}
15
+ type=${INPUT_TYPE[column.filter.type]}
16
+ value=${entity.filters[column.id]}
17
+ @input=${(event) => {
18
+ const value = event.target.value
19
+ const formattedValue = value ? new Date(value).getTime() : null
20
+
21
+ api.notify(`#${entity.id}:filterChange`, {
22
+ columnId: column.id,
23
+ value: { ...filter, min: formattedValue },
24
+ })
25
+ }}
26
+ />
27
+ <input
28
+ name=${`${column.id}Max`}
29
+ type=${INPUT_TYPE[column.filter.type]}
30
+ value=${entity.filters[column.id]}
31
+ @input=${(event) => {
32
+ const value = event.target.value
33
+ const formattedValue = value ? new Date(value).getTime() : null
34
+
35
+ api.notify(`#${entity.id}:filterChange`, {
36
+ columnId: column.id,
37
+ value: { ...filter, max: formattedValue },
38
+ })
39
+ }}
40
+ />`
41
+ },
42
+ }
@@ -0,0 +1,22 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const numberFilter = {
4
+ render(entity, column, api) {
5
+ return html`<input
6
+ name=${column.id}
7
+ type="number"
8
+ placeholder=${column.filter.placeholder ?? "="}
9
+ value=${entity.filters[column.id]}
10
+ @input=${(event) => {
11
+ const value = event.target.value
12
+ const formattedValue = value ? Number(value) : null
13
+
14
+ api.notify(`#${entity.id}:filterChange`, {
15
+ columnId: column.id,
16
+ value: formattedValue,
17
+ })
18
+ }}
19
+ class="iw-table-cell-number"
20
+ />`
21
+ },
22
+ }
@@ -0,0 +1,42 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const rangeFilter = {
4
+ render(entity, column, api) {
5
+ const filter = entity.filters[column.id] ?? {}
6
+
7
+ return html`<div class="row">
8
+ <input
9
+ name=${`${column.id}Min`}
10
+ type="number"
11
+ placeholder=${column.filter.placeholder ?? "≥"}
12
+ value=${filter.min}
13
+ @input=${(event) => {
14
+ const value = event.target.value
15
+ const formattedValue = value ? Number(value) : null
16
+
17
+ api.notify(`#${entity.id}:filterChange`, {
18
+ columnId: column.id,
19
+ value: { ...filter, min: formattedValue },
20
+ })
21
+ }}
22
+ class="iw-table-cell-number"
23
+ />
24
+ <input
25
+ name=${`${column.id}Max`}
26
+ type="number"
27
+ placeholder=${column.filter.placeholder ?? "≤"}
28
+ value=${filter.max}
29
+ @input=${(event) => {
30
+ const value = event.target.value
31
+ const formattedValue = value ? Number(value) : null
32
+
33
+ api.notify(`#${entity.id}:filterChange`, {
34
+ columnId: column.id,
35
+ value: { ...filter, max: formattedValue },
36
+ })
37
+ }}
38
+ class="iw-table-cell-number"
39
+ />
40
+ </div>`
41
+ },
42
+ }
@@ -0,0 +1,34 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const selectFilter = {
4
+ render(entity, column, api) {
5
+ return html`<select
6
+ name=${column.id}
7
+ ?multiple=${column.filter.isMultiple}
8
+ autocomplete="off"
9
+ value=${entity.filters[column.id]}
10
+ @change=${(event) => {
11
+ const value = event.target.value
12
+ const formattedValue = value ? format(value, column.type) : null
13
+
14
+ api.notify(`#${entity.id}:filterChange`, {
15
+ columnId: column.id,
16
+ value: formattedValue,
17
+ })
18
+ }}
19
+ >
20
+ ${column.filter.options.map(
21
+ (option) => html`<option value=${option}>${option}</option>`,
22
+ )}
23
+ </select>`
24
+ },
25
+ }
26
+
27
+ function format(value, type) {
28
+ if (type === "number") return Number(value)
29
+ if (type === "boolean")
30
+ return value === "true" ? true : value === "false" ? false : null
31
+ if (["date", "time", "datetime"].includes(type))
32
+ return new Date(value).getTime()
33
+ return value
34
+ }
@@ -0,0 +1,22 @@
1
+ import { html } from "@inglorious/web"
2
+
3
+ export const textFilter = {
4
+ render(entity, column, api) {
5
+ return html`<input
6
+ name=${column.id}
7
+ type="text"
8
+ placeholder=${column.filter.placeholder ?? "Contains..."}
9
+ autocomplete="off"
10
+ value=${entity.filters[column.id]}
11
+ @input=${(event) => {
12
+ const value = event.target.value
13
+ const formattedValue = value || null
14
+
15
+ api.notify(`#${entity.id}:filterChange`, {
16
+ columnId: column.id,
17
+ value: formattedValue,
18
+ })
19
+ }}
20
+ />`
21
+ },
22
+ }
@@ -0,0 +1,24 @@
1
+ import { choose, html } from "@inglorious/web"
2
+
3
+ import { dateFilter } from "./filters/date"
4
+ import { numberFilter } from "./filters/number"
5
+ import { rangeFilter } from "./filters/range"
6
+ import { selectFilter } from "./filters/select"
7
+ import { textFilter } from "./filters/text"
8
+
9
+ export const filters = {
10
+ render(entity, column, api) {
11
+ return html`${choose(
12
+ column.filter.type,
13
+ [
14
+ ["number", () => numberFilter.render(entity, column, api)],
15
+ ["range", () => rangeFilter.render(entity, column, api)],
16
+ ["select", () => selectFilter.render(entity, column, api)],
17
+ ["date", () => dateFilter.render(entity, column, api)],
18
+ ["time", () => dateFilter.render(entity, column, api)],
19
+ ["datetime", () => dateFilter.render(entity, column, api)],
20
+ ],
21
+ () => textFilter.render(entity, column, api),
22
+ )}`
23
+ },
24
+ }
@@ -0,0 +1,401 @@
1
+ /* eslint-disable no-magic-numbers */
2
+
3
+ export const logic = {
4
+ init(entity) {
5
+ initTable(entity)
6
+ },
7
+
8
+ create(entity) {
9
+ initTable(entity)
10
+ },
11
+
12
+ sortChange(entity, columnId) {
13
+ const column = entity.columns.find((c) => c.id === columnId)
14
+ if (!column?.isSortable) return
15
+
16
+ const existingIndex = entity.sorts.findIndex((s) => s.column === columnId)
17
+
18
+ if (existingIndex !== -1) {
19
+ // Toggle direction
20
+ const existing = entity.sorts[existingIndex]
21
+ if (existing.direction === "asc") {
22
+ existing.direction = "desc"
23
+ } else {
24
+ // Remove from sort if going from desc back to nothing
25
+ entity.sorts.splice(existingIndex, 1)
26
+ }
27
+ } else {
28
+ // Add new sort
29
+ entity.sorts.push({ column: columnId, direction: "asc" })
30
+ }
31
+
32
+ if (entity.pagination) {
33
+ entity.pagination.page = 0
34
+ }
35
+ },
36
+
37
+ sortsClear(entity) {
38
+ entity.sorts = []
39
+ },
40
+
41
+ filterChange(entity, { columnId, value }) {
42
+ if (value == null || value === "") {
43
+ delete entity.filters[columnId]
44
+ } else {
45
+ entity.filters[columnId] = value
46
+ }
47
+
48
+ // Reset to first page when filtering
49
+ if (entity.pagination) {
50
+ entity.pagination.page = 0
51
+ }
52
+ },
53
+
54
+ filtersClear(entity) {
55
+ entity.filters = {}
56
+ if (entity.pagination) {
57
+ entity.pagination.page = 0
58
+ }
59
+ },
60
+
61
+ searchChange(entity, search) {
62
+ entity.search.value = search
63
+ },
64
+
65
+ pageChange(entity, page) {
66
+ if (!entity.pagination) return
67
+
68
+ const totalPages = Math.ceil(
69
+ getTotalRows(entity) / entity.pagination.pageSize,
70
+ )
71
+ entity.pagination.page = Math.max(0, Math.min(page, totalPages - 1))
72
+ },
73
+
74
+ pageNext(entity) {
75
+ if (!entity.pagination) return
76
+ const totalPages = Math.ceil(
77
+ getTotalRows(entity) / entity.pagination.pageSize,
78
+ )
79
+ entity.pagination.page = Math.min(
80
+ entity.pagination.page + 1,
81
+ totalPages - 1,
82
+ )
83
+ },
84
+
85
+ pagePrev(entity) {
86
+ if (!entity.pagination) return
87
+ entity.pagination.page = Math.max(entity.pagination.page - 1, 0)
88
+ },
89
+
90
+ pageSizeChange(entity, pageSize) {
91
+ if (!entity.pagination) return
92
+
93
+ entity.pagination.pageSize = pageSize
94
+ entity.pagination.page = 0
95
+ },
96
+
97
+ rowSelect(entity, rowId) {
98
+ if (!entity.isMultiSelect) {
99
+ entity.selection = []
100
+ }
101
+
102
+ if (!entity.selection.includes(rowId)) {
103
+ entity.selection.push(rowId)
104
+ }
105
+ },
106
+
107
+ rowDeselect(entity, rowId) {
108
+ const index = entity.selection.indexOf(rowId)
109
+ if (index !== -1) {
110
+ entity.selection.splice(index, 1)
111
+ }
112
+ },
113
+
114
+ rowToggle(entity, rowId) {
115
+ const index = entity.selection.indexOf(rowId)
116
+
117
+ if (index === -1) {
118
+ if (!entity.isMultiSelect) {
119
+ entity.selection = [rowId] // Replace entirely
120
+ } else {
121
+ entity.selection.push(rowId)
122
+ }
123
+ } else {
124
+ entity.selection.splice(index, 1)
125
+ }
126
+ },
127
+
128
+ rowsToggleAll(entity) {
129
+ const rows = getRows(entity)
130
+ const allSelected = rows.every((row) => entity.selection.includes(row.id))
131
+
132
+ if (allSelected) {
133
+ // Deselect all visible
134
+ rows.forEach((row) => {
135
+ const index = entity.selection.indexOf(row.id)
136
+ if (index !== -1) entity.selection.splice(index, 1)
137
+ })
138
+ } else {
139
+ // Select all visible
140
+ rows.forEach((row) => {
141
+ if (!entity.selection.includes(row.id)) {
142
+ entity.selection.push(row.id)
143
+ }
144
+ })
145
+ }
146
+ },
147
+
148
+ rowsSelectAll(entity) {
149
+ const rows = getRows(entity)
150
+ rows.forEach((row) => {
151
+ if (!entity.selection.includes(row.id)) {
152
+ entity.selection.push(row.id)
153
+ }
154
+ })
155
+ },
156
+
157
+ selectionClear(entity) {
158
+ entity.selection.length = 0
159
+ },
160
+ }
161
+
162
+ // Helper functions outside the type (like form helpers)
163
+
164
+ export function getRows(entity) {
165
+ let rows = entity.data
166
+ rows = applyFilters(entity, rows)
167
+ rows = applySearch(entity, rows)
168
+ rows = applySorts(entity, rows)
169
+ rows = applyPagination(entity, rows)
170
+
171
+ return rows
172
+ }
173
+
174
+ export function getTotalRows(entity) {
175
+ let rows = entity.data
176
+ rows = applyFilters(entity, rows)
177
+ rows = applySearch(entity, rows)
178
+ return rows.length
179
+ }
180
+
181
+ export function getPaginationInfo(entity) {
182
+ if (!entity.pagination) return null
183
+
184
+ const totalRows = getTotalRows(entity)
185
+ const { page, pageSize } = entity.pagination
186
+ const totalPages = Math.ceil(totalRows / pageSize)
187
+ const start = page * pageSize
188
+ const end = Math.min((page + 1) * pageSize, totalRows)
189
+
190
+ return {
191
+ page,
192
+ pageSize,
193
+ totalPages,
194
+ totalRows,
195
+ start,
196
+ end,
197
+ hasNextPage: page < totalPages - 1,
198
+ hasPrevPage: page > 0,
199
+ }
200
+ }
201
+
202
+ export function getSortDirection(entity, columnId) {
203
+ const sort = entity.sorts.find((s) => s.column === columnId)
204
+ return sort?.direction || null
205
+ }
206
+
207
+ export function getSortIndex(entity, columnId) {
208
+ return entity.sorts.findIndex((s) => s.column === columnId)
209
+ }
210
+
211
+ export function getFilter(entity, columnId) {
212
+ return entity.filters[columnId]
213
+ }
214
+
215
+ export function isRowSelected(entity, rowId) {
216
+ return entity.selection.includes(rowId)
217
+ }
218
+
219
+ export function isAllSelected(entity) {
220
+ const rows = getRows(entity)
221
+ return rows.length && rows.every((row) => entity.selection.includes(row.id))
222
+ }
223
+
224
+ export function isSomeSelected(entity) {
225
+ const rows = getRows(entity)
226
+ const selectedCount = rows.filter((row) =>
227
+ entity.selection.includes(row.id),
228
+ ).length
229
+ return selectedCount && selectedCount < rows.length
230
+ }
231
+
232
+ function initTable(entity) {
233
+ entity.data ??= []
234
+
235
+ // Auto-generate columns from first data item if not provided
236
+ if (!entity.columns && entity.data.length) {
237
+ const [firstRow] = entity.data
238
+
239
+ entity.columns = Object.keys(firstRow).map((key) => {
240
+ const value = firstRow[key]
241
+ const type = getDefaultColumnType(value)
242
+ const filter = getDefaultColumnFilter(type)
243
+
244
+ return {
245
+ id: key,
246
+ title: capitalize(key),
247
+ type,
248
+ isSortable: false,
249
+ isFilterable: false,
250
+ filter,
251
+ width: getDefaultColumnWidth(filter.type),
252
+ }
253
+ })
254
+ } else {
255
+ entity.columns ??= []
256
+ entity.columns.forEach((column) => {
257
+ column.title ??= capitalize(column.id)
258
+ column.type ??= getDefaultColumnType()
259
+ column.filter ??= getDefaultColumnFilter(column.type)
260
+ column.width ??= getDefaultColumnWidth(column.filter.type)
261
+ })
262
+ }
263
+
264
+ // State
265
+ entity.sorts ??= []
266
+ entity.filters ??= {}
267
+ entity.search ??= null
268
+ if (entity.search) {
269
+ entity.search.value ??= ""
270
+ }
271
+ entity.selection ??= []
272
+
273
+ entity.pagination ??= null
274
+ if (entity.pagination) {
275
+ entity.pagination.page ??= 0
276
+ }
277
+ }
278
+
279
+ function getDefaultColumnType(value) {
280
+ if (typeof value === "number") return "number"
281
+ if (typeof value === "boolean") return "boolean"
282
+ if (value instanceof Date) return "date"
283
+ return "string"
284
+ }
285
+
286
+ function getDefaultColumnFilter(type) {
287
+ if (type === "number") return { type: "range" }
288
+ if (type === "boolean")
289
+ return { type: "select", options: [null, true, false] }
290
+ if (type === "date") return { type: "date" }
291
+ return { type: "text" }
292
+ }
293
+
294
+ function getDefaultColumnWidth(filterType) {
295
+ if (filterType === "number") return 100
296
+ if (filterType === "range") return 200
297
+ if (filterType === "select") return 70
298
+ if (filterType === "date") return 120
299
+ if (filterType === "time") return 120
300
+ if (filterType === "datetime") return 170
301
+ return 200
302
+ }
303
+
304
+ function applyFilters(entity, rows) {
305
+ if (!Object.keys(entity.filters).length) {
306
+ return rows
307
+ }
308
+
309
+ return rows.filter((row) => {
310
+ return Object.entries(entity.filters).every(([columnId, filterValue]) => {
311
+ const column = entity.columns.find((c) => c.id === columnId)
312
+ if (!column) return true
313
+
314
+ // Custom filter function
315
+ if (column.filterFn) {
316
+ return column.filterFn(row, filterValue)
317
+ }
318
+
319
+ // Default filters by type
320
+ const value = row[columnId]
321
+
322
+ if (["range", "date", "time", "datetime"].includes(column.filter.type)) {
323
+ const { min, max } = filterValue
324
+ if (min != null && value < min) return false
325
+ if (max != null && value > max) return false
326
+ return true
327
+ }
328
+
329
+ if (["number", "boolean", "select"].includes(column.filter.type)) {
330
+ return value === filterValue
331
+ }
332
+
333
+ // String filtering (case-insensitive contains)
334
+ return String(value)
335
+ .toLowerCase()
336
+ .includes(String(filterValue).toLowerCase())
337
+ })
338
+ })
339
+ }
340
+
341
+ function applySearch(entity, rows) {
342
+ if (!entity.search?.value) {
343
+ return rows
344
+ }
345
+
346
+ const searchLower = entity.search.value.toLowerCase()
347
+
348
+ return rows.filter((row) =>
349
+ entity.columns.some((column) => {
350
+ const value = row[column.id]
351
+ const formattedValue = column.format?.(value) ?? String(value)
352
+ return formattedValue.toLowerCase().includes(searchLower)
353
+ }),
354
+ )
355
+ }
356
+
357
+ function applySorts(entity, rows) {
358
+ if (!entity.sorts.length) {
359
+ return rows
360
+ }
361
+
362
+ return [...rows].sort((a, b) => {
363
+ for (const { column: columnId, direction } of entity.sorts) {
364
+ const column = entity.columns.find((c) => c.id === columnId)
365
+ let aVal = a[columnId]
366
+ let bVal = b[columnId]
367
+
368
+ // Custom sort function
369
+ if (column?.sortFn) {
370
+ const result =
371
+ direction === "asc" ? column.sortFn(a, b) : column.sortFn(b, a)
372
+ if (result !== 0) return result
373
+ continue
374
+ }
375
+
376
+ // Default sorting
377
+ if (aVal === bVal) continue
378
+ if (aVal == null) return 1
379
+ if (bVal == null) return -1
380
+
381
+ const comparison = aVal < bVal ? -1 : 1
382
+ return direction === "asc" ? comparison : -comparison
383
+ }
384
+ return 0
385
+ })
386
+ }
387
+
388
+ function applyPagination(entity, rows) {
389
+ if (!entity.pagination) {
390
+ return rows
391
+ }
392
+
393
+ const { page, pageSize } = entity.pagination
394
+ const start = page * pageSize
395
+ return rows.slice(start, start + pageSize)
396
+ }
397
+
398
+ function capitalize(str) {
399
+ const [firstChar, ...rest] = str
400
+ return [firstChar.toUpperCase(), ...rest].join("")
401
+ }
@@ -0,0 +1,239 @@
1
+ import { html } from "lit-html"
2
+ import { ref } from "lit-html/directives/ref.js"
3
+
4
+ import { filters } from "./filters"
5
+ import { getPaginationInfo, getRows, getSortDirection } from "./logic"
6
+
7
+ const DIVISOR = 2
8
+ const FIRST_PAGE = 0
9
+ const LAST_PAGE = 1
10
+ const PRETTY_PAGE = 1
11
+ const PERCENTAGE_TO_FLEX = 0.01
12
+
13
+ export const rendering = {
14
+ mount(entity, containerEl) {
15
+ const columns = containerEl.querySelectorAll(":scope > *")
16
+ ;[...columns].forEach((column, index) => {
17
+ entity.columns[index].width = column.offsetWidth
18
+ })
19
+ },
20
+
21
+ render(entity, api) {
22
+ const type = api.getType(entity.type)
23
+
24
+ return html`<div class="iw-table">
25
+ ${type.renderHeader(entity, api)} ${type.renderBody(entity, api)}
26
+ ${type.renderFooter(entity, api)}
27
+ </div> `
28
+ },
29
+
30
+ renderHeader(entity, api) {
31
+ const type = api.getType(entity.type)
32
+
33
+ return html`<div class="iw-table-header">
34
+ <div
35
+ class="iw-table-header-row"
36
+ ${ref((el) => {
37
+ if (
38
+ el &&
39
+ entity.columns.some(({ width }) => typeof width === "string")
40
+ ) {
41
+ queueMicrotask(() => {
42
+ api.notify(`#${entity.id}:mount`, el)
43
+ })
44
+ }
45
+ })}
46
+ >
47
+ ${entity.columns.map((column) =>
48
+ type.renderHeaderColumn(entity, column, api),
49
+ )}
50
+ </div>
51
+
52
+ ${entity.search && type.renderSearchbar(entity, api)}
53
+ </div>`
54
+ },
55
+
56
+ renderHeaderColumn(entity, column, api) {
57
+ return html`<div
58
+ class="iw-table-header-column"
59
+ style=${getColumnStyle(column)}
60
+ >
61
+ <div
62
+ @click=${() =>
63
+ column.isSortable &&
64
+ api.notify(`#${entity.id}:sortChange`, column.id)}
65
+ class="iw-table-header-title"
66
+ >
67
+ ${column.title} ${getSortIcon(getSortDirection(entity, column.id))}
68
+ </div>
69
+
70
+ ${column.isFilterable && filters.render(entity, column, api)}
71
+ </div>`
72
+ },
73
+
74
+ renderSearchbar(entity, api) {
75
+ return html`<input
76
+ name="search"
77
+ type="text"
78
+ placeholder=${entity.search.placeholder ?? "Fuzzy search..."}
79
+ value=${entity.search.value}
80
+ @input=${(event) =>
81
+ api.notify(`#${entity.id}:searchChange`, event.target.value)}
82
+ class="iw-table-searchbar"
83
+ />`
84
+ },
85
+
86
+ renderBody(entity, api) {
87
+ const type = api.getType(entity.type)
88
+
89
+ return html`<div class="iw-table-body">
90
+ ${getRows(entity).map((row, index) =>
91
+ type.renderRow(entity, row, index, api),
92
+ )}
93
+ </div>`
94
+ },
95
+
96
+ renderRow(entity, row, index, api) {
97
+ const type = api.getType(entity.type)
98
+ const rowId = row[entity.rowId ?? "id"]
99
+
100
+ return html`<div
101
+ @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
+ : ""}"
107
+ >
108
+ ${Object.values(row).map((value, index) =>
109
+ type.renderCell(entity, value, index, api),
110
+ )}
111
+ </div>`
112
+ },
113
+
114
+ renderCell(entity, cell, index, api) {
115
+ const type = api.getType(entity.type)
116
+ const column = entity.columns[index]
117
+
118
+ 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
+ style=${getColumnStyle(column)}
121
+ >
122
+ ${type.renderValue(cell, column, api)}
123
+ </div>`
124
+ },
125
+
126
+ renderValue(value) {
127
+ return value
128
+ },
129
+
130
+ renderFooter(entity, api) {
131
+ const type = api.getType(entity.type)
132
+ const pagination = getPaginationInfo(entity)
133
+
134
+ return html`<div class="iw-table-footer">
135
+ <div class="iw-table-footer-row">
136
+ <div>
137
+ ${pagination.start + PRETTY_PAGE} to ${pagination.end} of ${pagination.totalRows}
138
+ entries
139
+ </div>
140
+
141
+ ${type.renderPagination(entity, pagination, api)}
142
+
143
+ <div class="iw-table-footer-row">
144
+ <div>Page size:</div>
145
+ <select
146
+ name="pageSize"
147
+ @change=${(event) =>
148
+ api.notify(
149
+ `#${entity.id}:pageSizeChange`,
150
+ Number(event.target.value),
151
+ )}
152
+ >
153
+ <option>10</option>
154
+ <option>20</option>
155
+ <option>30</option>
156
+ </select>
157
+ </div>
158
+ </div>
159
+ </div>
160
+ </div>`
161
+ },
162
+
163
+ renderPagination(entity, pagination, api) {
164
+ return html`<div class="iw-table-row">
165
+ <button
166
+ ?disabled=${!pagination.hasPrevPage}
167
+ @click=${() => api.notify(`#${entity.id}:pageChange`, FIRST_PAGE)}
168
+ class="iw-table-pagination-button"
169
+ >
170
+ |&#10094;
171
+ </button>
172
+ <button
173
+ ?disabled=${!pagination.hasPrevPage}
174
+ @click=${() => api.notify(`#${entity.id}:pagePrev`)}
175
+ class="iw-table-pagination-button"
176
+ >
177
+ &#10094;
178
+ </button>
179
+ <input
180
+ name="page"
181
+ type="number"
182
+ min="1"
183
+ max=${pagination.totalPages}
184
+ value=${pagination.page + PRETTY_PAGE}
185
+ class=${`iw-table-page-input`}
186
+ @input=${(event) =>
187
+ api.notify(
188
+ `#${entity.id}:pageChange`,
189
+ Number(event.target.value) - PRETTY_PAGE,
190
+ )}
191
+ />
192
+ /
193
+ <span>${pagination.totalPages}</span>
194
+ <button
195
+ ?disabled="${!pagination.hasNextPage}"
196
+ @click=${() => api.notify(`#${entity.id}:pageNext`)}
197
+ class="iw-table-pagination-button"
198
+ >
199
+ &#10095;
200
+ </button>
201
+ <button
202
+ ?disabled=${!pagination.hasNextPage}
203
+ @click=${() =>
204
+ api.notify(
205
+ `#${entity.id}:pageChange`,
206
+ pagination.totalPages - LAST_PAGE,
207
+ )}
208
+ class="iw-table-pagination-button"
209
+ >
210
+ &#10095;|
211
+ </button>
212
+ </div>`
213
+ },
214
+ }
215
+
216
+ function getColumnStyle(column) {
217
+ if (typeof column.width === "string") {
218
+ if (column.width?.endsWith("%")) {
219
+ // eslint-disable-next-line no-magic-numbers
220
+ const percentage = Number(column.width.slice(0, -1))
221
+ return `flex: ${percentage * PERCENTAGE_TO_FLEX}`
222
+ }
223
+
224
+ return `width: ${column.width}`
225
+ }
226
+
227
+ return `width: ${column.width}px`
228
+ }
229
+
230
+ function getSortIcon(direction) {
231
+ switch (direction) {
232
+ case "asc":
233
+ return "▲"
234
+ case "desc":
235
+ return "▼"
236
+ default:
237
+ return "▲▼"
238
+ }
239
+ }
@@ -0,0 +1,83 @@
1
+ .iw-table {
2
+ .iw-table-header {
3
+ border-bottom: 1px solid grey;
4
+ padding: 1em 0;
5
+ row-gap: 1em;
6
+ }
7
+
8
+ .iw-table-header-row {
9
+ align-items: flex-start;
10
+ column-gap: 1em;
11
+ }
12
+
13
+ .iw-table-header-column {
14
+ row-gap: 0.5em;
15
+ }
16
+
17
+ .iw-table-header-title {
18
+ font-weight: bold;
19
+ white-space: nowrap;
20
+ }
21
+
22
+ .iw-table-body {
23
+ max-height: 35em;
24
+ overflow: auto;
25
+ border-bottom: 1px solid grey;
26
+ }
27
+
28
+ .iw-table-row {
29
+ align-items: center;
30
+ column-gap: 1em;
31
+ }
32
+
33
+ .iw-table-row-even {
34
+ background-color: aliceblue;
35
+ }
36
+
37
+ .iw-table-row-selected {
38
+ border-left: 1px solid cornflowerblue;
39
+ border-right: 1px solid cornflowerblue;
40
+ }
41
+
42
+ :not(.iw-table-row-selected) + .iw-table-row-selected {
43
+ border-top: 1px solid cornflowerblue;
44
+ margin-top: -1px;
45
+ }
46
+
47
+ .iw-table-row-selected + :not(.iw-table-row-selected) {
48
+ border-top: 1px solid cornflowerblue;
49
+ margin-top: -1px;
50
+ }
51
+
52
+ .iw-table-cell {
53
+ padding: 1em;
54
+ }
55
+
56
+ .iw-table-cell-number,
57
+ .iw-table-cell-date {
58
+ text-align: right;
59
+ }
60
+
61
+ .iw-table-cell-boolean {
62
+ text-align: center;
63
+ }
64
+
65
+ .iw-table-footer {
66
+ padding: 1em 0;
67
+ }
68
+
69
+ .iw-table-footer-row {
70
+ justify-content: space-between;
71
+ align-items: center;
72
+ column-gap: 1em;
73
+ }
74
+
75
+ .iw-table-page-input {
76
+ min-width: 4em;
77
+ text-align: right;
78
+ }
79
+
80
+ .iw-table-pagination-button {
81
+ white-space: nowrap;
82
+ }
83
+ }
package/src/table.js ADDED
@@ -0,0 +1,7 @@
1
+ import { logic } from "./table/logic"
2
+ import { rendering } from "./table/rendering"
3
+
4
+ export const table = {
5
+ ...logic,
6
+ ...rendering,
7
+ }