@inglorious/web 2.0.1 → 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.
@@ -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
+ }