@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.
@@ -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
+ }