@vobs/table 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +57 -0
- package/package.json +33 -0
- package/src/column-settings.ts +381 -0
- package/src/data-table.test.ts +536 -0
- package/src/data-table.ts +686 -0
- package/src/index.ts +28 -0
- package/src/persistence.ts +99 -0
- package/src/styles/styles.css +1 -0
- package/src/styles/table.css +209 -0
- package/src/types.ts +183 -0
- package/src/utils.ts +172 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
import { effect, state } from '@vobs/reactivity'
|
|
2
|
+
import {
|
|
3
|
+
addEventListener,
|
|
4
|
+
createElement,
|
|
5
|
+
createFragment,
|
|
6
|
+
createText,
|
|
7
|
+
insertBefore,
|
|
8
|
+
insertDynamic,
|
|
9
|
+
setAttribute,
|
|
10
|
+
setProperty,
|
|
11
|
+
type VobsNode
|
|
12
|
+
} from '@vobs/vobs'
|
|
13
|
+
import {
|
|
14
|
+
bindClassList,
|
|
15
|
+
bindCommonAttributes,
|
|
16
|
+
bindStyle,
|
|
17
|
+
hasProp,
|
|
18
|
+
normalizePixels,
|
|
19
|
+
normalizeColumnSettings,
|
|
20
|
+
orderColumns,
|
|
21
|
+
readProp,
|
|
22
|
+
resolveSlot,
|
|
23
|
+
setOptionalAttribute
|
|
24
|
+
} from './utils'
|
|
25
|
+
import type {
|
|
26
|
+
DataTableColumn,
|
|
27
|
+
DataTableChildren,
|
|
28
|
+
DataTableColumnSettings,
|
|
29
|
+
DataTableIcons,
|
|
30
|
+
DataTablePage,
|
|
31
|
+
DataTableQuery,
|
|
32
|
+
DataTableResourceData,
|
|
33
|
+
DataTableSort,
|
|
34
|
+
DataTableSortType,
|
|
35
|
+
KitDataTableProps
|
|
36
|
+
} from './types'
|
|
37
|
+
|
|
38
|
+
const internalQueries = new WeakMap<object, ReturnType<typeof state<DataTableQuery>>>()
|
|
39
|
+
|
|
40
|
+
const DEFAULT_PAGE_SIZE = 25
|
|
41
|
+
const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const
|
|
42
|
+
const DEFAULT_ROW_HEIGHT = 40
|
|
43
|
+
const DEFAULT_VIRTUAL_HEIGHT = 400
|
|
44
|
+
const textCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
|
|
45
|
+
|
|
46
|
+
export function KitDataTable<Row = Record<string, unknown>>(props: KitDataTableProps<Row> = {}): VobsNode {
|
|
47
|
+
const root = createElement('section')
|
|
48
|
+
const toolbar = createElement('div')
|
|
49
|
+
const viewport = createElement('div')
|
|
50
|
+
const table = createElement('table')
|
|
51
|
+
const head = createElement('thead')
|
|
52
|
+
const body = createElement('tbody')
|
|
53
|
+
const footer = createElement('footer')
|
|
54
|
+
const scrollTop = state(0)
|
|
55
|
+
internalQueries.set(props, state({
|
|
56
|
+
page: 1,
|
|
57
|
+
pageSize: DEFAULT_PAGE_SIZE,
|
|
58
|
+
sort: null,
|
|
59
|
+
filters: {}
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
bindClassList(root, props, () => [
|
|
63
|
+
'vobs-data-table',
|
|
64
|
+
readProp(props, 'stickyHeader', true) ? 'vobs-data-table--sticky-header' : undefined
|
|
65
|
+
])
|
|
66
|
+
bindCommonAttributes(root, props, [
|
|
67
|
+
'columns', 'rows', 'resource', 'rowKey', 'page', 'pageSize', 'total', 'sort', 'filters', 'columnSettings',
|
|
68
|
+
'sortingMode', 'visibleColumnIds', 'virtual', 'stickyHeader', 'virtualHeight', 'rowHeight', 'overscan', 'loading', 'empty',
|
|
69
|
+
'error', 'toolbar', 'footer', 'pagination', 'paginationMode', 'icons',
|
|
70
|
+
'previousLabel', 'nextLabel', 'jumpToLabel', 'jumpToPlaceholder', 'jumpToSubmitLabel',
|
|
71
|
+
'pageSizeOptions', 'pageSizeLabel', 'pageLabel',
|
|
72
|
+
'onQueryChange', 'onRowClick', 'onVisibleColumnIdsChange'
|
|
73
|
+
])
|
|
74
|
+
bindStyle(root, props)
|
|
75
|
+
setAttribute(toolbar, 'class', 'vobs-data-table__toolbar')
|
|
76
|
+
setAttribute(viewport, 'class', 'vobs-data-table__viewport')
|
|
77
|
+
setAttribute(viewport, 'data-vobs-scrollable', 'true')
|
|
78
|
+
setAttribute(table, 'class', 'vobs-data-table__table')
|
|
79
|
+
setAttribute(footer, 'class', 'vobs-data-table__footer')
|
|
80
|
+
|
|
81
|
+
if (hasProp(props, 'toolbar')) insertDynamic(toolbar, null, () => resolveSlot(readProp(props, 'toolbar', undefined)))
|
|
82
|
+
effect(() => { setProperty(toolbar, 'hidden', !hasProp(props, 'toolbar')) })
|
|
83
|
+
effect(() => {
|
|
84
|
+
const virtual = readProp(props, 'virtual', false)
|
|
85
|
+
setProperty(viewport, 'tabIndex', virtual ? 0 : -1)
|
|
86
|
+
setAttribute(viewport, 'style', virtual ? `max-height: ${normalizeHeight(readProp(props, 'virtualHeight', DEFAULT_VIRTUAL_HEIGHT))}; overflow: auto` : '')
|
|
87
|
+
})
|
|
88
|
+
addEventListener(viewport, 'scroll', () => { scrollTop.value = viewport.scrollTop })
|
|
89
|
+
|
|
90
|
+
insertDynamic(head, null, () => createHeader(props))
|
|
91
|
+
insertDynamic(body, null, () => createBody(props, scrollTop.value))
|
|
92
|
+
insertBefore(table, head, null)
|
|
93
|
+
insertBefore(table, body, null)
|
|
94
|
+
insertBefore(viewport, table, null)
|
|
95
|
+
insertDynamic(footer, null, () => createFooter(props))
|
|
96
|
+
effect(() => { setProperty(footer, 'hidden', !shouldShowFooter(props)) })
|
|
97
|
+
|
|
98
|
+
insertBefore(root, toolbar, null)
|
|
99
|
+
insertBefore(root, viewport, null)
|
|
100
|
+
insertBefore(root, footer, null)
|
|
101
|
+
return root
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function createHeader<Row>(props: KitDataTableProps<Row>): VobsNode {
|
|
105
|
+
const columns = visibleColumns(props)
|
|
106
|
+
return createFragment((parent, anchor) => {
|
|
107
|
+
const row = createElement('tr')
|
|
108
|
+
for (const column of columns) {
|
|
109
|
+
const cell = createElement('th')
|
|
110
|
+
setAttribute(cell, 'scope', 'col')
|
|
111
|
+
setAttribute(cell, 'data-column-id', column.id)
|
|
112
|
+
applyColumnStyle(cell, column, readProp<DataTableColumnSettings | undefined>(props, 'columnSettings', undefined))
|
|
113
|
+
if (column.sortable) insertBefore(cell, createSortButton(props, column, cell), null)
|
|
114
|
+
else insertBefore(cell, createText(column.label), null)
|
|
115
|
+
insertBefore(row, cell, null)
|
|
116
|
+
}
|
|
117
|
+
insertBefore(parent, row, anchor)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createSortButton<Row>(props: KitDataTableProps<Row>, column: DataTableColumn<Row>, cell: Element): VobsNode {
|
|
122
|
+
const button = createElement('button')
|
|
123
|
+
const label = createElement('span')
|
|
124
|
+
const icon = createElement('span')
|
|
125
|
+
setAttribute(button, 'type', 'button')
|
|
126
|
+
setAttribute(button, 'class', 'vobs-data-table__sort')
|
|
127
|
+
setAttribute(button, 'aria-label', `Sort by ${column.label}`)
|
|
128
|
+
setAttribute(label, 'class', 'vobs-data-table__sort-label')
|
|
129
|
+
setAttribute(icon, 'class', 'vobs-data-table__sort-icon')
|
|
130
|
+
insertBefore(label, createText(column.label), null)
|
|
131
|
+
insertDynamic(icon, null, () => resolveSlot(sortIcon(props, currentSort(props), column.id)))
|
|
132
|
+
effect(() => {
|
|
133
|
+
const sort = currentSort(props)
|
|
134
|
+
const direction = sort?.columnId === column.id ? sort.direction : undefined
|
|
135
|
+
setOptionalAttribute(cell, 'aria-sort', direction
|
|
136
|
+
? direction === 'asc' ? 'ascending' : 'descending'
|
|
137
|
+
: undefined)
|
|
138
|
+
setOptionalAttribute(cell, 'data-sort-direction', direction)
|
|
139
|
+
})
|
|
140
|
+
addEventListener(button, 'click', () => {
|
|
141
|
+
const current = currentSort(props)
|
|
142
|
+
const next = current?.columnId === column.id
|
|
143
|
+
? current.direction === 'asc' ? createSort(column, 'desc') : null
|
|
144
|
+
: createSort(column, 'asc')
|
|
145
|
+
emitQuery(props, { ...currentQuery(props), sort: next, page: 1 })
|
|
146
|
+
})
|
|
147
|
+
insertBefore(button, label, null)
|
|
148
|
+
insertBefore(button, icon, null)
|
|
149
|
+
return button
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createBody<Row>(props: KitDataTableProps<Row>, scrollTop: number): VobsNode {
|
|
153
|
+
const resource = readProp<KitDataTableProps<Row>['resource'] | undefined>(props, 'resource', undefined)
|
|
154
|
+
if (resource?.error.value) return createStateRow(props, 'error', resource.error.value)
|
|
155
|
+
if (resource?.loading.value && resource.data.value === null) return createStateRow(props, 'loading')
|
|
156
|
+
|
|
157
|
+
const page = resolvePage(props)
|
|
158
|
+
if (page.rows.length === 0) return createStateRow(props, 'empty')
|
|
159
|
+
const columns = visibleColumns(props)
|
|
160
|
+
const window = resolveWindow(props, page.rows.length, scrollTop)
|
|
161
|
+
const fragment = createFragment((parent, anchor) => {
|
|
162
|
+
if (window.before > 0) insertBefore(parent, createSpacerRow(columns.length, window.before), anchor)
|
|
163
|
+
for (let index = window.start; index < window.end; index++) {
|
|
164
|
+
insertBefore(parent, createRow(props, page.rows[index], index, columns), anchor)
|
|
165
|
+
}
|
|
166
|
+
if (window.after > 0) insertBefore(parent, createSpacerRow(columns.length, window.after), anchor)
|
|
167
|
+
})
|
|
168
|
+
return fragment
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function createStateRow<Row>(props: KitDataTableProps<Row>, kind: 'loading' | 'empty' | 'error', error?: Error): VobsNode {
|
|
172
|
+
const row = createElement('tr')
|
|
173
|
+
const cell = createElement('td')
|
|
174
|
+
setAttribute(cell, 'class', `vobs-data-table__state vobs-data-table__state--${kind}`)
|
|
175
|
+
setAttribute(cell, 'colspan', String(Math.max(1, visibleColumns(props).length)))
|
|
176
|
+
let content: ReturnType<typeof resolveSlot>
|
|
177
|
+
if (kind === 'error') {
|
|
178
|
+
const fallback = readProp<KitDataTableProps<Row>['error'] | undefined>(props, 'error', undefined)
|
|
179
|
+
const resource = readProp<KitDataTableProps<Row>['resource'] | undefined>(props, 'resource', undefined)
|
|
180
|
+
content = fallback ? resolveSlot(fallback(error!, () => resource?.refetch() ?? Promise.resolve())) : createText(error?.message ?? 'Unable to load data')
|
|
181
|
+
} else {
|
|
182
|
+
content = resolveSlot(readProp<DataTableChildren | undefined>(props, kind, undefined))
|
|
183
|
+
?? createText(kind === 'loading' ? 'Loading...' : 'No data')
|
|
184
|
+
}
|
|
185
|
+
if (content) insertBefore(cell, content, null)
|
|
186
|
+
insertBefore(row, cell, null)
|
|
187
|
+
return row
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function createRow<Row>(
|
|
191
|
+
props: KitDataTableProps<Row>,
|
|
192
|
+
row: Row,
|
|
193
|
+
index: number,
|
|
194
|
+
columns: readonly DataTableColumn<Row>[]
|
|
195
|
+
): VobsNode {
|
|
196
|
+
const tableRow = createElement('tr')
|
|
197
|
+
const rowKey = readProp<KitDataTableProps<Row>['rowKey'] | undefined>(props, 'rowKey', undefined)
|
|
198
|
+
if (rowKey) setOptionalAttribute(tableRow, 'data-row-key', rowKey(row, index))
|
|
199
|
+
const onRowClick = readProp<KitDataTableProps<Row>['onRowClick'] | undefined>(props, 'onRowClick', undefined)
|
|
200
|
+
if (onRowClick) {
|
|
201
|
+
setProperty(tableRow, 'tabIndex', 0)
|
|
202
|
+
setAttribute(tableRow, 'data-clickable', 'true')
|
|
203
|
+
addEventListener(tableRow, 'click', () => onRowClick(row, index))
|
|
204
|
+
}
|
|
205
|
+
for (const column of columns) {
|
|
206
|
+
const cell = createElement('td')
|
|
207
|
+
setAttribute(cell, 'data-column-id', column.id)
|
|
208
|
+
applyColumnStyle(cell, column, readProp<DataTableColumnSettings | undefined>(props, 'columnSettings', undefined))
|
|
209
|
+
insertDynamic(cell, null, () => resolveSlot(column.render
|
|
210
|
+
? column.render(row, index)
|
|
211
|
+
: column.key === undefined ? undefined : readRowValue(row, column.key)))
|
|
212
|
+
insertBefore(tableRow, cell, null)
|
|
213
|
+
}
|
|
214
|
+
return tableRow
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function createSpacerRow(columnCount: number, height: number): VobsNode {
|
|
218
|
+
const row = createElement('tr')
|
|
219
|
+
const cell = createElement('td')
|
|
220
|
+
setAttribute(cell, 'class', 'vobs-data-table__spacer')
|
|
221
|
+
setAttribute(cell, 'colspan', String(Math.max(1, columnCount)))
|
|
222
|
+
setAttribute(cell, 'style', `height: ${height}px`)
|
|
223
|
+
insertBefore(row, cell, null)
|
|
224
|
+
return row
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function createFooter<Row>(props: KitDataTableProps<Row>): VobsNode {
|
|
228
|
+
const custom = readProp<KitDataTableProps<Row>['footer'] | undefined>(props, 'footer', undefined)
|
|
229
|
+
if (custom !== undefined) return resolveSlot(custom) ?? createText('')
|
|
230
|
+
const pagination = resolvePaginationState(props)
|
|
231
|
+
const root = createElement('div')
|
|
232
|
+
const summary = createElement('span')
|
|
233
|
+
setAttribute(root, 'class', 'vobs-data-table__footer-content')
|
|
234
|
+
setAttribute(summary, 'class', 'vobs-data-table__summary')
|
|
235
|
+
insertBefore(summary, createText(pageLabel(
|
|
236
|
+
props,
|
|
237
|
+
pagination.page,
|
|
238
|
+
pagination.pageCount,
|
|
239
|
+
pagination.total,
|
|
240
|
+
pagination.visibleCount,
|
|
241
|
+
pagination.pageSize
|
|
242
|
+
)), null)
|
|
243
|
+
const actions = hasProp(props, 'pagination')
|
|
244
|
+
? createCustomPagination(props, pagination)
|
|
245
|
+
: createPagination(props, pagination)
|
|
246
|
+
insertBefore(root, summary, null)
|
|
247
|
+
if (actions) insertBefore(root, actions, null)
|
|
248
|
+
return root
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface DataTablePaginationState {
|
|
252
|
+
readonly page: number
|
|
253
|
+
readonly pageCount: number
|
|
254
|
+
readonly pageSize: number
|
|
255
|
+
readonly total: number
|
|
256
|
+
readonly visibleCount: number
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function createCustomPagination<Row>(
|
|
260
|
+
props: KitDataTableProps<Row>,
|
|
261
|
+
pagination: DataTablePaginationState
|
|
262
|
+
): VobsNode | null {
|
|
263
|
+
const custom = readProp<KitDataTableProps<Row>['pagination'] | undefined>(props, 'pagination', undefined)
|
|
264
|
+
if (custom === undefined) return null
|
|
265
|
+
const value = typeof custom === 'function'
|
|
266
|
+
? custom({
|
|
267
|
+
page: pagination.page,
|
|
268
|
+
pageCount: pagination.pageCount,
|
|
269
|
+
pageSize: pagination.pageSize,
|
|
270
|
+
pageSizeOptions: pageSizeOptions(props, pagination.pageSize),
|
|
271
|
+
total: pagination.total,
|
|
272
|
+
visibleCount: pagination.visibleCount,
|
|
273
|
+
goToPage: page => goToPage(props, page, pagination.pageCount),
|
|
274
|
+
setPageSize: pageSize => setPageSize(props, pageSize)
|
|
275
|
+
})
|
|
276
|
+
: custom
|
|
277
|
+
return resolveSlot(value)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function createPagination<Row>(
|
|
281
|
+
props: KitDataTableProps<Row>,
|
|
282
|
+
pagination: DataTablePaginationState
|
|
283
|
+
): VobsNode {
|
|
284
|
+
const root = createElement('div')
|
|
285
|
+
const mode = readProp<NonNullable<KitDataTableProps<Row>['paginationMode']>>(props, 'paginationMode', 'simple')
|
|
286
|
+
setAttribute(root, 'class', 'vobs-data-table__pagination')
|
|
287
|
+
setAttribute(root, 'data-mode', mode)
|
|
288
|
+
insertBefore(root, createPageButton(props, 'previous', pagination), null)
|
|
289
|
+
|
|
290
|
+
if (mode !== 'simple') {
|
|
291
|
+
const pages = createElement('div')
|
|
292
|
+
setAttribute(pages, 'class', 'vobs-data-table__pagination-pages')
|
|
293
|
+
for (const item of buildPaginationItems(pagination.page, pagination.pageCount)) {
|
|
294
|
+
insertBefore(pages, typeof item === 'number'
|
|
295
|
+
? createPageNumberButton(props, item, pagination.page)
|
|
296
|
+
: createPageEllipsis(item), null)
|
|
297
|
+
}
|
|
298
|
+
insertBefore(root, pages, null)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
insertBefore(root, createPageButton(props, 'next', pagination), null)
|
|
302
|
+
if (mode === 'all') insertBefore(root, createJumpControl(props, pagination), null)
|
|
303
|
+
insertBefore(root, createPageSizeControl(props, pagination), null)
|
|
304
|
+
return root
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function createPageButton<Row>(
|
|
308
|
+
props: KitDataTableProps<Row>,
|
|
309
|
+
direction: 'previous' | 'next',
|
|
310
|
+
pagination: DataTablePaginationState
|
|
311
|
+
): VobsNode {
|
|
312
|
+
const button = createElement('button')
|
|
313
|
+
const label = direction === 'previous'
|
|
314
|
+
? readProp(props, 'previousLabel', 'Previous')
|
|
315
|
+
: readProp(props, 'nextLabel', 'Next')
|
|
316
|
+
const disabled = direction === 'previous'
|
|
317
|
+
? pagination.page <= 1
|
|
318
|
+
: pagination.page >= pagination.pageCount
|
|
319
|
+
setAttribute(button, 'type', 'button')
|
|
320
|
+
setAttribute(button, 'class', 'vobs-data-table__page-button')
|
|
321
|
+
setAttribute(button, 'aria-label', label)
|
|
322
|
+
setProperty(button, 'disabled', disabled)
|
|
323
|
+
const icons = readProp<DataTableIcons | undefined>(props, 'icons', undefined) ?? {}
|
|
324
|
+
const icon = direction === 'previous' ? icons.previous : icons.next
|
|
325
|
+
const node = icon === undefined
|
|
326
|
+
? createText(direction === 'previous' ? '<' : '>')
|
|
327
|
+
: resolveSlot(icon)
|
|
328
|
+
if (node) insertBefore(button, node, null)
|
|
329
|
+
addEventListener(button, 'click', () => {
|
|
330
|
+
if (disabled) return
|
|
331
|
+
const query = currentQuery(props)
|
|
332
|
+
goToPage(props, direction === 'previous' ? query.page - 1 : query.page + 1, pagination.pageCount)
|
|
333
|
+
})
|
|
334
|
+
return button
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function createPageNumberButton<Row>(props: KitDataTableProps<Row>, page: number, currentPage: number): VobsNode {
|
|
338
|
+
const button = createElement('button')
|
|
339
|
+
const active = page === currentPage
|
|
340
|
+
setAttribute(button, 'type', 'button')
|
|
341
|
+
setAttribute(button, 'class', `vobs-data-table__page-button${active ? ' is-active' : ''}`)
|
|
342
|
+
setAttribute(button, 'aria-label', `Page ${page}`)
|
|
343
|
+
if (active) setAttribute(button, 'aria-current', 'page')
|
|
344
|
+
insertBefore(button, createText(String(page)), null)
|
|
345
|
+
addEventListener(button, 'click', () => goToPage(props, page, resolvePaginationState(props).pageCount))
|
|
346
|
+
return button
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function createPageEllipsis(side: 'ellipsis-left' | 'ellipsis-right'): VobsNode {
|
|
350
|
+
const ellipsis = createElement('span')
|
|
351
|
+
setAttribute(ellipsis, 'class', 'vobs-data-table__page-ellipsis')
|
|
352
|
+
setAttribute(ellipsis, 'aria-hidden', 'true')
|
|
353
|
+
setAttribute(ellipsis, 'data-side', side)
|
|
354
|
+
insertBefore(ellipsis, createText('...'), null)
|
|
355
|
+
return ellipsis
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function createJumpControl<Row>(props: KitDataTableProps<Row>, pagination: DataTablePaginationState): VobsNode {
|
|
359
|
+
const root = createElement('form')
|
|
360
|
+
const label = createElement('label')
|
|
361
|
+
const input = createElement('input') as HTMLInputElement
|
|
362
|
+
const submit = createElement('button')
|
|
363
|
+
const jumpLabel = readProp(props, 'jumpToLabel', 'Go to')
|
|
364
|
+
setAttribute(root, 'class', 'vobs-data-table__pagination-jump')
|
|
365
|
+
setAttribute(label, 'class', 'vobs-data-table__jump-label')
|
|
366
|
+
setAttribute(input, 'class', 'vobs-data-table__jump-input')
|
|
367
|
+
setAttribute(input, 'type', 'text')
|
|
368
|
+
setAttribute(input, 'inputmode', 'numeric')
|
|
369
|
+
setAttribute(input, 'pattern', '[0-9]*')
|
|
370
|
+
setAttribute(input, 'autocomplete', 'off')
|
|
371
|
+
setAttribute(input, 'aria-label', jumpLabel)
|
|
372
|
+
setAttribute(input, 'placeholder', readProp(props, 'jumpToPlaceholder', 'Page'))
|
|
373
|
+
setProperty(input, 'value', String(pagination.page))
|
|
374
|
+
setAttribute(submit, 'type', 'submit')
|
|
375
|
+
setAttribute(submit, 'class', 'vobs-data-table__page-button vobs-data-table__jump-submit')
|
|
376
|
+
setAttribute(submit, 'aria-label', readProp(props, 'jumpToSubmitLabel', 'Go'))
|
|
377
|
+
insertBefore(label, createText(jumpLabel), null)
|
|
378
|
+
insertBefore(label, input, null)
|
|
379
|
+
insertBefore(submit, createText(readProp(props, 'jumpToSubmitLabel', 'Go')), null)
|
|
380
|
+
addEventListener(input, 'input', () => {
|
|
381
|
+
const value = input.value.replace(/[^0-9]/gu, '')
|
|
382
|
+
if (value !== input.value) setProperty(input, 'value', value)
|
|
383
|
+
})
|
|
384
|
+
addEventListener(root, 'submit', event => {
|
|
385
|
+
event.preventDefault()
|
|
386
|
+
if (!/^\d+$/u.test(input.value)) return
|
|
387
|
+
const requested = Number(input.value)
|
|
388
|
+
if (!Number.isSafeInteger(requested)) return
|
|
389
|
+
goToPage(props, requested, pagination.pageCount)
|
|
390
|
+
})
|
|
391
|
+
insertBefore(root, label, null)
|
|
392
|
+
insertBefore(root, submit, null)
|
|
393
|
+
return root
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function createPageSizeControl<Row>(props: KitDataTableProps<Row>, pagination: DataTablePaginationState): VobsNode {
|
|
397
|
+
const root = createElement('label')
|
|
398
|
+
const select = createElement('select') as HTMLSelectElement
|
|
399
|
+
const label = readProp(props, 'pageSizeLabel', 'Rows per page')
|
|
400
|
+
setAttribute(root, 'class', 'vobs-data-table__page-size')
|
|
401
|
+
setAttribute(select, 'class', 'vobs-data-table__page-size-select')
|
|
402
|
+
setAttribute(select, 'aria-label', label)
|
|
403
|
+
insertBefore(root, createText(label), null)
|
|
404
|
+
for (const optionValue of pageSizeOptions(props, pagination.pageSize)) {
|
|
405
|
+
const option = createElement('option') as HTMLOptionElement
|
|
406
|
+
setAttribute(option, 'value', String(optionValue))
|
|
407
|
+
setProperty(option, 'textContent', String(optionValue))
|
|
408
|
+
insertBefore(select, option, null)
|
|
409
|
+
}
|
|
410
|
+
setProperty(select, 'value', String(pagination.pageSize))
|
|
411
|
+
addEventListener(select, 'change', () => {
|
|
412
|
+
setPageSize(props, Number(select.value))
|
|
413
|
+
})
|
|
414
|
+
insertBefore(root, select, null)
|
|
415
|
+
return root
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function resolvePage<Row>(props: KitDataTableProps<Row>): { rows: readonly Row[]; total: number } {
|
|
419
|
+
const resource = readProp<KitDataTableProps<Row>['resource'] | undefined>(props, 'resource', undefined)
|
|
420
|
+
const supplied = resource?.data.value ?? readProp<readonly Row[]>(props, 'rows', [])
|
|
421
|
+
const source = normalizeRows(supplied)
|
|
422
|
+
const query = currentQuery(props)
|
|
423
|
+
const rows = applyFiltersAndSort(source.rows, visibleColumns(props), query, readProp(props, 'sortingMode', 'client'))
|
|
424
|
+
const remoteTotal = source.total ?? readProp<number | undefined>(props, 'total', undefined)
|
|
425
|
+
const total = remoteTotal ?? rows.length
|
|
426
|
+
const isRemotePage = source.total !== undefined || readProp(props, 'total', undefined) !== undefined
|
|
427
|
+
if (isRemotePage) return { rows, total }
|
|
428
|
+
const start = (query.page - 1) * query.pageSize
|
|
429
|
+
return { rows: rows.slice(start, start + query.pageSize), total }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeRows<Row>(value: DataTableResourceData<Row> | readonly Row[]): DataTablePage<Row> {
|
|
433
|
+
if (isRowArray(value)) return { rows: value }
|
|
434
|
+
return { rows: value.rows ?? [], total: value.total }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function isRowArray<Row>(value: DataTableResourceData<Row> | readonly Row[]): value is readonly Row[] {
|
|
438
|
+
return Array.isArray(value)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function applyFiltersAndSort<Row>(
|
|
442
|
+
rows: readonly Row[],
|
|
443
|
+
columns: readonly DataTableColumn<Row>[],
|
|
444
|
+
query: DataTableQuery,
|
|
445
|
+
sortingMode: 'client' | 'server'
|
|
446
|
+
): readonly Row[] {
|
|
447
|
+
let result = rows
|
|
448
|
+
for (const column of columns) {
|
|
449
|
+
const value = query.filters[column.id]
|
|
450
|
+
if (value === undefined || value === null || value === '') continue
|
|
451
|
+
result = result.filter(row => column.filter
|
|
452
|
+
? column.filter(row, value)
|
|
453
|
+
: String(column.key === undefined ? '' : readRowValue(row, column.key))
|
|
454
|
+
.toLocaleLowerCase().includes(String(value).toLocaleLowerCase()))
|
|
455
|
+
}
|
|
456
|
+
if (!query.sort || sortingMode === 'server') return result
|
|
457
|
+
const column = columns.find(candidate => candidate.id === query.sort!.columnId)
|
|
458
|
+
if (!column) return result
|
|
459
|
+
const direction = query.sort.direction
|
|
460
|
+
return result
|
|
461
|
+
.map((row, index) => ({ row, index }))
|
|
462
|
+
.sort((left, right) => {
|
|
463
|
+
const compared = compareRows(left.row, right.row, column, direction)
|
|
464
|
+
return compared === 0 ? left.index - right.index : compared
|
|
465
|
+
})
|
|
466
|
+
.map(entry => entry.row)
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function compareRows<Row>(
|
|
470
|
+
left: Row,
|
|
471
|
+
right: Row,
|
|
472
|
+
column: DataTableColumn<Row>,
|
|
473
|
+
direction: 'asc' | 'desc'
|
|
474
|
+
): number {
|
|
475
|
+
const multiplier = direction === 'asc' ? 1 : -1
|
|
476
|
+
if (column.compare) return multiplier * column.compare(left, right)
|
|
477
|
+
const leftValue = column.sortValue
|
|
478
|
+
? column.sortValue(left)
|
|
479
|
+
: column.key === undefined ? undefined : readRowValue(left, column.key)
|
|
480
|
+
const rightValue = column.sortValue
|
|
481
|
+
? column.sortValue(right)
|
|
482
|
+
: column.key === undefined ? undefined : readRowValue(right, column.key)
|
|
483
|
+
const leftMissing = isMissingSortValue(leftValue, column.sortType)
|
|
484
|
+
const rightMissing = isMissingSortValue(rightValue, column.sortType)
|
|
485
|
+
if (leftMissing || rightMissing) {
|
|
486
|
+
if (leftMissing && rightMissing) return 0
|
|
487
|
+
return leftMissing ? 1 : -1
|
|
488
|
+
}
|
|
489
|
+
return multiplier * compareSortValues(leftValue, rightValue, column.sortType)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function compareSortValues(left: unknown, right: unknown, sortType: DataTableSortType | undefined): number {
|
|
493
|
+
if (sortType === 'number') return compareNumbers(left, right)
|
|
494
|
+
if (sortType === 'date') return compareDates(left, right)
|
|
495
|
+
if (sortType === 'boolean') return Number(Boolean(left)) - Number(Boolean(right))
|
|
496
|
+
if (sortType === 'text') return compareText(left, right)
|
|
497
|
+
if (typeof left === 'number' && typeof right === 'number') return compareNumbers(left, right)
|
|
498
|
+
if (left instanceof Date && right instanceof Date) return compareNumbers(left.getTime(), right.getTime())
|
|
499
|
+
if (typeof left === 'boolean' && typeof right === 'boolean') return Number(left) - Number(right)
|
|
500
|
+
return compareText(left, right)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function compareNumbers(left: unknown, right: unknown): number {
|
|
504
|
+
return Number(left) - Number(right)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function compareDates(left: unknown, right: unknown): number {
|
|
508
|
+
const leftTime = left instanceof Date ? left.getTime() : Date.parse(String(left))
|
|
509
|
+
const rightTime = right instanceof Date ? right.getTime() : Date.parse(String(right))
|
|
510
|
+
return leftTime - rightTime
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function compareText(left: unknown, right: unknown): number {
|
|
514
|
+
return textCollator.compare(String(left), String(right))
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function createSort<Row>(column: DataTableColumn<Row>, direction: 'asc' | 'desc'): DataTableSort {
|
|
518
|
+
return column.sortKey
|
|
519
|
+
? { columnId: column.id, direction, sortKey: column.sortKey }
|
|
520
|
+
: { columnId: column.id, direction }
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function isMissingSortValue(value: unknown, sortType: DataTableSortType | undefined): boolean {
|
|
524
|
+
if (value === undefined || value === null || value === '') return true
|
|
525
|
+
if (sortType === 'number') return !Number.isFinite(Number(value))
|
|
526
|
+
if (sortType === 'date') {
|
|
527
|
+
const timestamp = value instanceof Date ? value.getTime() : Date.parse(String(value))
|
|
528
|
+
return !Number.isFinite(timestamp)
|
|
529
|
+
}
|
|
530
|
+
return false
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function resolveWindow<Row>(props: KitDataTableProps<Row>, count: number, scrollTop: number): { start: number; end: number; before: number; after: number } {
|
|
534
|
+
if (!readProp(props, 'virtual', false)) return { start: 0, end: count, before: 0, after: 0 }
|
|
535
|
+
const rowHeight = normalizePositive(readProp(props, 'rowHeight', DEFAULT_ROW_HEIGHT), DEFAULT_ROW_HEIGHT)
|
|
536
|
+
const viewportHeight = normalizePositive(readProp(props, 'virtualHeight', DEFAULT_VIRTUAL_HEIGHT), DEFAULT_VIRTUAL_HEIGHT)
|
|
537
|
+
const overscan = Math.max(0, Math.floor(readProp(props, 'overscan', 5)))
|
|
538
|
+
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
|
|
539
|
+
const end = Math.min(count, Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan)
|
|
540
|
+
return { start, end, before: start * rowHeight, after: (count - end) * rowHeight }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function currentQuery<Row>(props: KitDataTableProps<Row>): DataTableQuery {
|
|
544
|
+
const internal = internalQueries.get(props)?.value
|
|
545
|
+
const page = readProp(props, 'page', internal?.page ?? 1)
|
|
546
|
+
const pageSize = readProp(props, 'pageSize', internal?.pageSize ?? DEFAULT_PAGE_SIZE)
|
|
547
|
+
return {
|
|
548
|
+
page: Math.max(1, Math.floor(page)),
|
|
549
|
+
pageSize: normalizePositive(pageSize, DEFAULT_PAGE_SIZE),
|
|
550
|
+
sort: readProp<DataTableSort | null>(props, 'sort', internal?.sort ?? null),
|
|
551
|
+
filters: readProp<Readonly<Record<string, unknown>>>(props, 'filters', internal?.filters ?? {})
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function emitQuery<Row>(props: KitDataTableProps<Row>, query: DataTableQuery): void {
|
|
556
|
+
const internal = internalQueries.get(props)
|
|
557
|
+
if (internal) internal.value = query
|
|
558
|
+
readProp<KitDataTableProps<Row>['onQueryChange'] | undefined>(props, 'onQueryChange', undefined)?.(query)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function currentSort<Row>(props: KitDataTableProps<Row>): DataTableSort | null {
|
|
562
|
+
return currentQuery(props).sort
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function visibleColumns<Row>(props: KitDataTableProps<Row>): readonly DataTableColumn<Row>[] {
|
|
566
|
+
const columns = readProp<readonly DataTableColumn<Row>[]>(props, 'columns', [])
|
|
567
|
+
const rawSettings = readProp<DataTableColumnSettings | undefined>(props, 'columnSettings', undefined)
|
|
568
|
+
const settings = rawSettings ? normalizeColumnSettings(columns, rawSettings) : undefined
|
|
569
|
+
const ids = settings?.visibleColumnIds
|
|
570
|
+
?? readProp<readonly string[] | undefined>(props, 'visibleColumnIds', undefined)
|
|
571
|
+
const visible = ids ? new Set(ids) : undefined
|
|
572
|
+
return orderColumns(columns, settings?.columnOrder)
|
|
573
|
+
.filter(column => column.visible !== false && (!visible || visible.has(column.id)))
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function sortIcon<Row>(props: KitDataTableProps<Row>, sort: DataTableSort | null, columnId: string) {
|
|
577
|
+
const icons = readProp<DataTableIcons | undefined>(props, 'icons', undefined) ?? {}
|
|
578
|
+
if (sort?.columnId !== columnId) return icons.unsorted ?? ''
|
|
579
|
+
return sort.direction === 'asc' ? icons.ascending ?? '^' : icons.descending ?? 'v'
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function pageLabel<Row>(
|
|
583
|
+
props: KitDataTableProps<Row>,
|
|
584
|
+
page: number,
|
|
585
|
+
pageCount: number,
|
|
586
|
+
total: number,
|
|
587
|
+
visibleCount: number,
|
|
588
|
+
pageSize: number
|
|
589
|
+
): string {
|
|
590
|
+
return readProp<KitDataTableProps<Row>['pageLabel'] | undefined>(props, 'pageLabel', undefined)
|
|
591
|
+
?. (page, pageCount, total, visibleCount, pageSize) ?? `${visibleCount}/${pageSize} - ${page}/${pageCount}`
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function shouldShowFooter<Row>(props: KitDataTableProps<Row>): boolean {
|
|
595
|
+
return hasProp(props, 'footer')
|
|
596
|
+
|| hasProp(props, 'pagination')
|
|
597
|
+
|| hasProp(props, 'paginationMode')
|
|
598
|
+
|| hasProp(props, 'pageSizeOptions')
|
|
599
|
+
|| resolvePage(props).total > currentQuery(props).pageSize
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function resolvePaginationState<Row>(props: KitDataTableProps<Row>): DataTablePaginationState {
|
|
603
|
+
const page = resolvePage(props)
|
|
604
|
+
const query = currentQuery(props)
|
|
605
|
+
return {
|
|
606
|
+
page: query.page,
|
|
607
|
+
pageCount: Math.max(1, Math.ceil(page.total / query.pageSize)),
|
|
608
|
+
pageSize: query.pageSize,
|
|
609
|
+
total: page.total,
|
|
610
|
+
visibleCount: page.rows.length
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function goToPage<Row>(props: KitDataTableProps<Row>, page: number, pageCount: number): void {
|
|
615
|
+
const query = currentQuery(props)
|
|
616
|
+
const nextPage = clampPage(page, pageCount)
|
|
617
|
+
if (nextPage === query.page) return
|
|
618
|
+
emitQuery(props, { ...query, page: nextPage })
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function setPageSize<Row>(props: KitDataTableProps<Row>, pageSize: number): void {
|
|
622
|
+
const query = currentQuery(props)
|
|
623
|
+
const nextPageSize = normalizePositive(pageSize, query.pageSize)
|
|
624
|
+
if (nextPageSize === query.pageSize) return
|
|
625
|
+
emitQuery(props, { ...query, page: 1, pageSize: nextPageSize })
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function pageSizeOptions<Row>(props: KitDataTableProps<Row>, currentPageSize: number): readonly number[] {
|
|
629
|
+
const configured = readProp<readonly number[] | undefined>(props, 'pageSizeOptions', undefined)
|
|
630
|
+
?? DEFAULT_PAGE_SIZE_OPTIONS
|
|
631
|
+
const values = configured
|
|
632
|
+
.map(value => normalizePositive(value, 0))
|
|
633
|
+
.filter(value => value > 0)
|
|
634
|
+
if (!values.includes(currentPageSize)) values.push(currentPageSize)
|
|
635
|
+
return [...new Set(values)].sort((left, right) => left - right)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
type PaginationItem = number | 'ellipsis-left' | 'ellipsis-right'
|
|
639
|
+
|
|
640
|
+
function buildPaginationItems(page: number, pageCount: number): readonly PaginationItem[] {
|
|
641
|
+
const siblingCount = 1
|
|
642
|
+
const totalVisible = siblingCount * 2 + 5
|
|
643
|
+
if (pageCount <= totalVisible) {
|
|
644
|
+
return Array.from({ length: pageCount }, (_, index) => index + 1)
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const left = Math.max(page - siblingCount, 2)
|
|
648
|
+
const right = Math.min(page + siblingCount, pageCount - 1)
|
|
649
|
+
const items: PaginationItem[] = [1]
|
|
650
|
+
if (left > 2) items.push('ellipsis-left')
|
|
651
|
+
for (let value = left; value <= right; value++) items.push(value)
|
|
652
|
+
if (right < pageCount - 1) items.push('ellipsis-right')
|
|
653
|
+
items.push(pageCount)
|
|
654
|
+
return items
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function clampPage(page: number, pageCount: number): number {
|
|
658
|
+
if (pageCount <= 0) return 1
|
|
659
|
+
return Math.min(pageCount, Math.max(1, Math.floor(Number.isFinite(page) ? page : 1)))
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function applyColumnStyle<Row>(
|
|
663
|
+
cell: Element,
|
|
664
|
+
column: DataTableColumn<Row>,
|
|
665
|
+
settings?: DataTableColumnSettings
|
|
666
|
+
): void {
|
|
667
|
+
const width = settings?.columnWidths[column.id] ?? column.width
|
|
668
|
+
const styles = [
|
|
669
|
+
normalizePixels(width) ? `width: ${normalizePixels(width)}` : '',
|
|
670
|
+
normalizePixels(column.minWidth) ? `min-width: ${normalizePixels(column.minWidth)}` : ''
|
|
671
|
+
].filter(Boolean).join('; ')
|
|
672
|
+
if (styles) setAttribute(cell, 'style', styles)
|
|
673
|
+
setOptionalAttribute(cell, 'data-align', column.align)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function normalizeHeight(value: number): string {
|
|
677
|
+
return `${normalizePositive(value, DEFAULT_VIRTUAL_HEIGHT)}px`
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function normalizePositive(value: number, fallback: number): number {
|
|
681
|
+
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function readRowValue<Row>(row: Row, key: keyof Row): unknown {
|
|
685
|
+
return row === null || row === undefined ? undefined : Reflect.get(row as object, key)
|
|
686
|
+
}
|