sv5ui 1.3.0 → 1.5.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 +16 -11
- package/dist/Checkbox/Checkbox.svelte +2 -11
- package/dist/CheckboxGroup/CheckboxGroup.svelte +2 -11
- package/dist/Collapsible/Collapsible.svelte +69 -0
- package/dist/Collapsible/Collapsible.svelte.d.ts +6 -0
- package/dist/Collapsible/CollapsibleTestWrapper.svelte +17 -0
- package/dist/Collapsible/CollapsibleTestWrapper.svelte.d.ts +4 -0
- package/dist/Collapsible/collapsible.types.d.ts +75 -0
- package/dist/Collapsible/collapsible.types.js +1 -0
- package/dist/Collapsible/collapsible.variants.d.ts +53 -0
- package/dist/Collapsible/collapsible.variants.js +21 -0
- package/dist/Collapsible/index.d.ts +2 -0
- package/dist/Collapsible/index.js +1 -0
- package/dist/Command/Command.svelte +183 -0
- package/dist/Command/Command.svelte.d.ts +6 -0
- package/dist/Command/CommandTestWrapper.svelte +13 -0
- package/dist/Command/CommandTestWrapper.svelte.d.ts +4 -0
- package/dist/Command/command.types.d.ts +98 -0
- package/dist/Command/command.types.js +1 -0
- package/dist/Command/command.variants.d.ts +226 -0
- package/dist/Command/command.variants.js +86 -0
- package/dist/Command/index.d.ts +2 -0
- package/dist/Command/index.js +1 -0
- package/dist/FormField/FormField.svelte +2 -6
- package/dist/Input/Input.svelte +2 -10
- package/dist/PinInput/PinInput.svelte +2 -11
- package/dist/RadioGroup/RadioGroup.svelte +2 -11
- package/dist/Select/Select.svelte +2 -10
- package/dist/Select/select.variants.js +1 -1
- package/dist/SelectMenu/SelectMenu.svelte +2 -10
- package/dist/SelectMenu/select-menu.variants.js +1 -1
- package/dist/Slider/Slider.svelte +2 -11
- package/dist/Switch/Switch.svelte +2 -11
- package/dist/Table/Table.svelte +752 -0
- package/dist/Table/Table.svelte.d.ts +26 -0
- package/dist/Table/index.d.ts +2 -0
- package/dist/Table/index.js +1 -0
- package/dist/Table/table.types.d.ts +199 -0
- package/dist/Table/table.types.js +1 -0
- package/dist/Table/table.utils.d.ts +51 -0
- package/dist/Table/table.utils.js +166 -0
- package/dist/Table/table.variants.d.ts +205 -0
- package/dist/Table/table.variants.js +126 -0
- package/dist/Textarea/Textarea.svelte +2 -10
- package/dist/Toast/Toaster.svelte +618 -0
- package/dist/Toast/Toaster.svelte.d.ts +5 -0
- package/dist/Toast/index.d.ts +4 -0
- package/dist/Toast/index.js +2 -0
- package/dist/Toast/toast.d.ts +38 -0
- package/dist/Toast/toast.js +73 -0
- package/dist/Toast/toast.types.d.ts +19 -0
- package/dist/Toast/toast.types.js +1 -0
- package/dist/Toast/toast.variants.d.ts +7 -0
- package/dist/Toast/toast.variants.js +5 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +5 -1
- package/dist/hooks/index.d.ts +14 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/useClickOutside.svelte.d.ts +31 -0
- package/dist/hooks/useClickOutside.svelte.js +37 -0
- package/dist/hooks/useClipboard.svelte.d.ts +30 -0
- package/dist/hooks/useClipboard.svelte.js +45 -0
- package/dist/hooks/useDebounce.svelte.d.ts +36 -0
- package/dist/hooks/useDebounce.svelte.js +56 -0
- package/dist/hooks/useEscapeKeydown.svelte.d.ts +31 -0
- package/dist/hooks/useEscapeKeydown.svelte.js +37 -0
- package/dist/hooks/useFormField.svelte.d.ts +21 -0
- package/dist/hooks/useFormField.svelte.js +17 -0
- package/dist/hooks/useInfiniteScroll.svelte.d.ts +57 -0
- package/dist/hooks/useInfiniteScroll.svelte.js +69 -0
- package/dist/hooks/useMediaQuery.svelte.d.ts +31 -0
- package/dist/hooks/useMediaQuery.svelte.js +38 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -0
- package/dist/theme.css +36 -0
- package/package.json +2 -1
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { TableProps } from './table.types.js'
|
|
3
|
+
|
|
4
|
+
export type Props = TableProps
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -->
|
|
8
|
+
<script lang="ts" generics="T extends Record<string, any>">
|
|
9
|
+
import type { SortItem, SortState, TableColumn } from './table.types.js'
|
|
10
|
+
import { tableVariants, tableDefaults } from './table.variants.js'
|
|
11
|
+
import { getComponentConfig, iconsDefaults } from '../config.js'
|
|
12
|
+
import Icon from '../Icon/Icon.svelte'
|
|
13
|
+
import Button from '../Button/Button.svelte'
|
|
14
|
+
import Checkbox from '../Checkbox/Checkbox.svelte'
|
|
15
|
+
import {
|
|
16
|
+
autoGenerateColumns,
|
|
17
|
+
getRowKey,
|
|
18
|
+
sortData,
|
|
19
|
+
filterByGlobal,
|
|
20
|
+
filterByColumns,
|
|
21
|
+
paginateData,
|
|
22
|
+
resolveVisibleColumns,
|
|
23
|
+
computePinOffsets,
|
|
24
|
+
formatCellValue
|
|
25
|
+
} from './table.utils.js'
|
|
26
|
+
|
|
27
|
+
const config = getComponentConfig('table', tableDefaults)
|
|
28
|
+
const icons = getComponentConfig('icons', iconsDefaults)
|
|
29
|
+
|
|
30
|
+
let {
|
|
31
|
+
ref = $bindable(null),
|
|
32
|
+
as = 'div',
|
|
33
|
+
data = [] as T[],
|
|
34
|
+
columns: columnsProp,
|
|
35
|
+
rowKey,
|
|
36
|
+
caption,
|
|
37
|
+
|
|
38
|
+
// Sorting
|
|
39
|
+
sorting = $bindable([]),
|
|
40
|
+
multiSort = false,
|
|
41
|
+
manualSorting = false,
|
|
42
|
+
onSortingChange,
|
|
43
|
+
|
|
44
|
+
// Global Filter
|
|
45
|
+
globalFilter = $bindable(''),
|
|
46
|
+
globalFilterKeys,
|
|
47
|
+
manualFiltering = false,
|
|
48
|
+
onGlobalFilterChange,
|
|
49
|
+
|
|
50
|
+
// Column Filters
|
|
51
|
+
columnFilters = $bindable({}),
|
|
52
|
+
onColumnFiltersChange,
|
|
53
|
+
|
|
54
|
+
// Pagination
|
|
55
|
+
page = $bindable(0),
|
|
56
|
+
pageSize = 10,
|
|
57
|
+
manualPagination = false,
|
|
58
|
+
onPageChange,
|
|
59
|
+
|
|
60
|
+
// Row Selection
|
|
61
|
+
selection,
|
|
62
|
+
selectedRows = $bindable([]),
|
|
63
|
+
onSelectionChange,
|
|
64
|
+
|
|
65
|
+
// Column Visibility
|
|
66
|
+
columnVisibility = $bindable(),
|
|
67
|
+
onColumnVisibilityChange,
|
|
68
|
+
|
|
69
|
+
// Column Pinning
|
|
70
|
+
columnPinning = $bindable(),
|
|
71
|
+
|
|
72
|
+
// Row Pinning
|
|
73
|
+
pinnedRows = $bindable([]),
|
|
74
|
+
onPinnedRowsChange,
|
|
75
|
+
|
|
76
|
+
// Row Expanding
|
|
77
|
+
expandedRows = $bindable([]),
|
|
78
|
+
onExpandedChange,
|
|
79
|
+
|
|
80
|
+
// Column Resizing
|
|
81
|
+
columnSizing = $bindable({}),
|
|
82
|
+
onColumnSizingChange,
|
|
83
|
+
|
|
84
|
+
// Visual
|
|
85
|
+
loading = false,
|
|
86
|
+
loadingColor = config.defaultVariants.loadingColor ?? 'primary',
|
|
87
|
+
loadingAnimation = config.defaultVariants.loadingAnimation ?? 'carousel',
|
|
88
|
+
empty = 'No data.',
|
|
89
|
+
striped = false,
|
|
90
|
+
hoverable = config.defaultVariants.hoverable ?? true,
|
|
91
|
+
sticky = false,
|
|
92
|
+
|
|
93
|
+
// Callbacks
|
|
94
|
+
onRowClick,
|
|
95
|
+
onRowHover,
|
|
96
|
+
onRowContextmenu,
|
|
97
|
+
|
|
98
|
+
// Styling
|
|
99
|
+
ui,
|
|
100
|
+
class: className,
|
|
101
|
+
|
|
102
|
+
// Slots
|
|
103
|
+
captionSlot,
|
|
104
|
+
emptySlot,
|
|
105
|
+
loadingSlot,
|
|
106
|
+
expandedSlot,
|
|
107
|
+
bodyTopSlot,
|
|
108
|
+
bodyBottomSlot,
|
|
109
|
+
headerSlot,
|
|
110
|
+
cellSlot,
|
|
111
|
+
|
|
112
|
+
...restProps
|
|
113
|
+
}: TableProps<T> = $props()
|
|
114
|
+
|
|
115
|
+
// =========================================================================
|
|
116
|
+
// Change notifications — skip initial mount, fire only on subsequent changes
|
|
117
|
+
// =========================================================================
|
|
118
|
+
let _prevGlobalFilter = globalFilter
|
|
119
|
+
let _prevColumnFilters = columnFilters
|
|
120
|
+
let _prevPage = page
|
|
121
|
+
let _prevColumnVisibility = columnVisibility
|
|
122
|
+
let _prevPinnedRows = pinnedRows
|
|
123
|
+
let _prevExpandedRows = expandedRows
|
|
124
|
+
|
|
125
|
+
$effect(() => {
|
|
126
|
+
if (globalFilter !== _prevGlobalFilter) {
|
|
127
|
+
_prevGlobalFilter = globalFilter
|
|
128
|
+
onGlobalFilterChange?.(globalFilter)
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
$effect(() => {
|
|
132
|
+
if (columnFilters !== _prevColumnFilters) {
|
|
133
|
+
_prevColumnFilters = columnFilters
|
|
134
|
+
onColumnFiltersChange?.(columnFilters)
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
$effect(() => {
|
|
138
|
+
if (page !== _prevPage) {
|
|
139
|
+
_prevPage = page
|
|
140
|
+
onPageChange?.(page)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
$effect(() => {
|
|
144
|
+
if (columnVisibility !== _prevColumnVisibility) {
|
|
145
|
+
_prevColumnVisibility = columnVisibility
|
|
146
|
+
if (columnVisibility !== undefined) onColumnVisibilityChange?.(columnVisibility)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
$effect(() => {
|
|
150
|
+
if (pinnedRows !== _prevPinnedRows) {
|
|
151
|
+
_prevPinnedRows = pinnedRows
|
|
152
|
+
onPinnedRowsChange?.(pinnedRows)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
$effect(() => {
|
|
156
|
+
if (expandedRows !== _prevExpandedRows) {
|
|
157
|
+
_prevExpandedRows = expandedRows
|
|
158
|
+
onExpandedChange?.(expandedRows)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// =========================================================================
|
|
163
|
+
// Resolved Columns
|
|
164
|
+
// =========================================================================
|
|
165
|
+
const resolvedColumns = $derived.by((): TableColumn<T>[] => {
|
|
166
|
+
if (columnsProp && columnsProp.length > 0) return columnsProp
|
|
167
|
+
return autoGenerateColumns(data)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const visibleColumns = $derived(
|
|
171
|
+
resolveVisibleColumns(resolvedColumns, columnVisibility, columnPinning)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
const hasFooter = $derived(visibleColumns.some((col) => col.footer))
|
|
175
|
+
const totalColspan = $derived(visibleColumns.length + (selection === 'multiple' ? 1 : 0))
|
|
176
|
+
|
|
177
|
+
// =========================================================================
|
|
178
|
+
// Data Pipeline: filter → sort → paginate
|
|
179
|
+
// =========================================================================
|
|
180
|
+
const filteredData = $derived.by(() => {
|
|
181
|
+
if (manualFiltering) return data
|
|
182
|
+
let result = data
|
|
183
|
+
if (globalFilter) {
|
|
184
|
+
result = filterByGlobal(result, globalFilter, globalFilterKeys)
|
|
185
|
+
}
|
|
186
|
+
if (columnFilters && Object.keys(columnFilters).length > 0) {
|
|
187
|
+
result = filterByColumns(result, columnFilters, resolvedColumns)
|
|
188
|
+
}
|
|
189
|
+
return result
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
const sortedData = $derived.by(() => {
|
|
193
|
+
if (manualSorting || sorting.length === 0) return filteredData
|
|
194
|
+
return sortData(filteredData, sorting, resolvedColumns)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const paginatedData = $derived.by(() => {
|
|
198
|
+
if (manualPagination) return sortedData
|
|
199
|
+
if (pageSize <= 0) return sortedData
|
|
200
|
+
return paginateData(sortedData, page, pageSize)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// =========================================================================
|
|
204
|
+
// Row Pinning — split paginated data into pinned (top) + unpinned
|
|
205
|
+
// =========================================================================
|
|
206
|
+
const pinnedKeySet = $derived(new Set(pinnedRows))
|
|
207
|
+
|
|
208
|
+
const pinnedData = $derived.by(() => {
|
|
209
|
+
if (pinnedRows.length === 0) return []
|
|
210
|
+
return sortedData.filter((row, i) => pinnedKeySet.has(getRowKey(row, rowKey, i)))
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const unpinnedPaginatedData = $derived.by(() => {
|
|
214
|
+
if (pinnedRows.length === 0) return paginatedData
|
|
215
|
+
return paginatedData.filter((row, i) => {
|
|
216
|
+
const absIdx = manualPagination ? i : page * pageSize + i
|
|
217
|
+
return !pinnedKeySet.has(getRowKey(row, rowKey, absIdx))
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
const hasVisibleRows = $derived(pinnedData.length > 0 || unpinnedPaginatedData.length > 0)
|
|
222
|
+
|
|
223
|
+
// =========================================================================
|
|
224
|
+
// Sorting
|
|
225
|
+
// =========================================================================
|
|
226
|
+
function getSortDirection(key: string): 'asc' | 'desc' | null {
|
|
227
|
+
const item = sorting.find((s) => s.key === key)
|
|
228
|
+
return item ? item.direction : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function toggleSort(key: string, event?: MouseEvent) {
|
|
232
|
+
const current = getSortDirection(key)
|
|
233
|
+
let next: SortItem | null
|
|
234
|
+
|
|
235
|
+
if (current === null) next = { key, direction: 'asc' }
|
|
236
|
+
else if (current === 'asc') next = { key, direction: 'desc' }
|
|
237
|
+
else next = null
|
|
238
|
+
|
|
239
|
+
let newSorting: SortState
|
|
240
|
+
|
|
241
|
+
if (multiSort && event?.shiftKey) {
|
|
242
|
+
const without = sorting.filter((s) => s.key !== key)
|
|
243
|
+
newSorting = next ? [...without, next] : without
|
|
244
|
+
} else {
|
|
245
|
+
newSorting = next ? [next] : []
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
sorting = newSorting
|
|
249
|
+
onSortingChange?.(newSorting)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// =========================================================================
|
|
253
|
+
// Row Selection — uses actual visible rows (pinned + unpinned on page)
|
|
254
|
+
// =========================================================================
|
|
255
|
+
const selectedKeySet = $derived.by(() => {
|
|
256
|
+
if (!selection) return new Set<string | number>()
|
|
257
|
+
return new Set(selectedRows.map((row, i) => getRowKey(row, rowKey, i)))
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
const actualVisibleRows = $derived.by((): { row: T; absIdx: number }[] => {
|
|
261
|
+
const rows: { row: T; absIdx: number }[] = []
|
|
262
|
+
for (let i = 0; i < pinnedData.length; i++) {
|
|
263
|
+
rows.push({ row: pinnedData[i], absIdx: i })
|
|
264
|
+
}
|
|
265
|
+
for (let i = 0; i < unpinnedPaginatedData.length; i++) {
|
|
266
|
+
const absIdx = manualPagination ? i : page * pageSize + i
|
|
267
|
+
rows.push({ row: unpinnedPaginatedData[i], absIdx })
|
|
268
|
+
}
|
|
269
|
+
return rows
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
function isRowSelected(row: T, index: number): boolean {
|
|
273
|
+
return selectedKeySet.has(getRowKey(row, rowKey, index))
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function toggleRowSelect(row: T, index: number) {
|
|
277
|
+
if (!selection) return
|
|
278
|
+
const key = getRowKey(row, rowKey, index)
|
|
279
|
+
|
|
280
|
+
if (selection === 'single') {
|
|
281
|
+
const isSelected = selectedKeySet.has(key)
|
|
282
|
+
const next = isSelected ? [] : [row]
|
|
283
|
+
selectedRows = next
|
|
284
|
+
onSelectionChange?.(next)
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const isSelected = selectedKeySet.has(key)
|
|
289
|
+
let next: T[]
|
|
290
|
+
if (isSelected) {
|
|
291
|
+
next = selectedRows.filter((r, i) => getRowKey(r, rowKey, i) !== key)
|
|
292
|
+
} else {
|
|
293
|
+
next = [...selectedRows, row]
|
|
294
|
+
}
|
|
295
|
+
selectedRows = next
|
|
296
|
+
onSelectionChange?.(next)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const allVisibleSelected = $derived(
|
|
300
|
+
selection === 'multiple' &&
|
|
301
|
+
actualVisibleRows.length > 0 &&
|
|
302
|
+
actualVisibleRows.every(({ row, absIdx }) => isRowSelected(row, absIdx))
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
const someVisibleSelected = $derived(
|
|
306
|
+
selection === 'multiple' &&
|
|
307
|
+
actualVisibleRows.length > 0 &&
|
|
308
|
+
actualVisibleRows.some(({ row, absIdx }) => isRowSelected(row, absIdx)) &&
|
|
309
|
+
!allVisibleSelected
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
function toggleSelectAll() {
|
|
313
|
+
if (!selection || selection !== 'multiple') return
|
|
314
|
+
|
|
315
|
+
let next: T[]
|
|
316
|
+
if (allVisibleSelected) {
|
|
317
|
+
const visibleKeys = new Set(
|
|
318
|
+
actualVisibleRows.map(({ row, absIdx }) => getRowKey(row, rowKey, absIdx))
|
|
319
|
+
)
|
|
320
|
+
next = selectedRows.filter((r, i) => !visibleKeys.has(getRowKey(r, rowKey, i)))
|
|
321
|
+
} else {
|
|
322
|
+
const existing = new Set(selectedRows.map((r, i) => getRowKey(r, rowKey, i)))
|
|
323
|
+
const toAdd = actualVisibleRows
|
|
324
|
+
.filter(({ row, absIdx }) => !existing.has(getRowKey(row, rowKey, absIdx)))
|
|
325
|
+
.map(({ row }) => row)
|
|
326
|
+
next = [...selectedRows, ...toAdd]
|
|
327
|
+
}
|
|
328
|
+
selectedRows = next
|
|
329
|
+
onSelectionChange?.(next)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// =========================================================================
|
|
333
|
+
// Row Expanding
|
|
334
|
+
// =========================================================================
|
|
335
|
+
function isRowExpanded(key: string | number): boolean {
|
|
336
|
+
return expandedRows.includes(key)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// =========================================================================
|
|
340
|
+
// Column Resizing
|
|
341
|
+
// =========================================================================
|
|
342
|
+
let resizing = $state<{ key: string; startX: number; startWidth: number } | null>(null)
|
|
343
|
+
|
|
344
|
+
function onResizeStart(e: MouseEvent, col: TableColumn<T>) {
|
|
345
|
+
e.preventDefault()
|
|
346
|
+
const currentWidth = columnSizing[col.key] ?? col.width ?? 150
|
|
347
|
+
resizing = { key: col.key, startX: e.clientX, startWidth: currentWidth }
|
|
348
|
+
|
|
349
|
+
const onMove = (ev: MouseEvent) => {
|
|
350
|
+
if (!resizing) return
|
|
351
|
+
const diff = ev.clientX - resizing.startX
|
|
352
|
+
let newWidth = resizing.startWidth + diff
|
|
353
|
+
const min = col.minWidth ?? 50
|
|
354
|
+
const max = col.maxWidth ?? Infinity
|
|
355
|
+
newWidth = Math.max(min, Math.min(max, newWidth))
|
|
356
|
+
columnSizing = { ...columnSizing, [resizing.key]: newWidth }
|
|
357
|
+
onColumnSizingChange?.(columnSizing)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const onUp = () => {
|
|
361
|
+
resizing = null
|
|
362
|
+
document.removeEventListener('mousemove', onMove)
|
|
363
|
+
document.removeEventListener('mouseup', onUp)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
document.addEventListener('mousemove', onMove)
|
|
367
|
+
document.addEventListener('mouseup', onUp)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// =========================================================================
|
|
371
|
+
// Row Interactions
|
|
372
|
+
// =========================================================================
|
|
373
|
+
const isSelectable = $derived(!!onRowClick || !!selection || !!onRowHover || !!onRowContextmenu)
|
|
374
|
+
|
|
375
|
+
function handleRowClick(e: MouseEvent, row: T, index: number) {
|
|
376
|
+
const target = e.target as HTMLElement
|
|
377
|
+
if (target.closest('button, a, input, select, textarea, [role="checkbox"]')) return
|
|
378
|
+
if (selection) toggleRowSelect(row, index)
|
|
379
|
+
onRowClick?.(row, index, e)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function handleRowKeyDown(e: KeyboardEvent, row: T, index: number) {
|
|
383
|
+
if (!onRowClick && !selection) return
|
|
384
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
385
|
+
e.preventDefault()
|
|
386
|
+
if (selection) toggleRowSelect(row, index)
|
|
387
|
+
onRowClick?.(row, index, e as unknown as MouseEvent)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function handleRowPointerEnter(e: PointerEvent, row: T, index: number) {
|
|
392
|
+
onRowHover?.(row, index, e)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function handleRowPointerLeave(e: PointerEvent, row: T, index: number) {
|
|
396
|
+
onRowHover?.(null, index, e)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function handleRowContextmenu(e: MouseEvent, row: T, index: number) {
|
|
400
|
+
onRowContextmenu?.(row, index, e)
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// =========================================================================
|
|
404
|
+
// Column Pinning
|
|
405
|
+
// =========================================================================
|
|
406
|
+
const pinOffsets = $derived(computePinOffsets(visibleColumns, columnSizing, columnPinning))
|
|
407
|
+
|
|
408
|
+
function getPinStyle(key: string): string {
|
|
409
|
+
const pin = pinOffsets.get(key)
|
|
410
|
+
if (!pin) return ''
|
|
411
|
+
return `${pin.side}: ${pin.offset}px`
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function isPinned(key: string): boolean {
|
|
415
|
+
return pinOffsets.has(key)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// =========================================================================
|
|
419
|
+
// Column Width Style
|
|
420
|
+
// =========================================================================
|
|
421
|
+
function getColWidth(col: TableColumn<T>): string | undefined {
|
|
422
|
+
const w = columnSizing[col.key] ?? col.width
|
|
423
|
+
return w ? `${w}px` : undefined
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// =========================================================================
|
|
427
|
+
// Align class
|
|
428
|
+
// =========================================================================
|
|
429
|
+
function getAlignClass(align?: 'left' | 'center' | 'right'): string {
|
|
430
|
+
if (align === 'center') return 'text-center'
|
|
431
|
+
if (align === 'right') return 'text-right'
|
|
432
|
+
return ''
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// =========================================================================
|
|
436
|
+
// Variant Classes
|
|
437
|
+
// =========================================================================
|
|
438
|
+
const variantSlots = $derived(
|
|
439
|
+
tableVariants({
|
|
440
|
+
hoverable: hoverable || undefined,
|
|
441
|
+
striped: striped || undefined,
|
|
442
|
+
sticky: sticky || undefined,
|
|
443
|
+
loading: loading || undefined,
|
|
444
|
+
loadingColor,
|
|
445
|
+
loadingAnimation
|
|
446
|
+
} as Parameters<typeof tableVariants>[0])
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
const pinnedVariantSlots = $derived(
|
|
450
|
+
tableVariants({ pinned: true } as Parameters<typeof tableVariants>[0])
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
const classes = $derived({
|
|
454
|
+
root: variantSlots.root({ class: [config.slots.root, className, ui?.root] }),
|
|
455
|
+
base: variantSlots.base({ class: [config.slots.base, ui?.base] }),
|
|
456
|
+
caption: variantSlots.caption({ class: [config.slots.caption, ui?.caption] }),
|
|
457
|
+
thead: variantSlots.thead({ class: [config.slots.thead, ui?.thead] }),
|
|
458
|
+
tbody: variantSlots.tbody({ class: [config.slots.tbody, ui?.tbody] }),
|
|
459
|
+
tfoot: variantSlots.tfoot({ class: [config.slots.tfoot, ui?.tfoot] }),
|
|
460
|
+
tr: variantSlots.tr({ class: [config.slots.tr, ui?.tr] }),
|
|
461
|
+
th: variantSlots.th({ class: [config.slots.th, ui?.th] }),
|
|
462
|
+
thPinned: pinnedVariantSlots.th({ class: [config.slots.th, ui?.th] }),
|
|
463
|
+
td: variantSlots.td({ class: [config.slots.td, ui?.td] }),
|
|
464
|
+
tdPinned: pinnedVariantSlots.td({ class: [config.slots.td, ui?.td] }),
|
|
465
|
+
separator: variantSlots.separator({ class: [config.slots.separator, ui?.separator] }),
|
|
466
|
+
empty: variantSlots.empty({ class: [config.slots.empty, ui?.empty] }),
|
|
467
|
+
loading: variantSlots.loading({ class: [config.slots.loading, ui?.loading] })
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
function thClass(key: string): string {
|
|
471
|
+
return isPinned(key) ? classes.thPinned : classes.th
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function tdClass(key: string): string {
|
|
475
|
+
return isPinned(key) ? classes.tdPinned : classes.td
|
|
476
|
+
}
|
|
477
|
+
</script>
|
|
478
|
+
|
|
479
|
+
{#snippet tableRow(row: T, absIdx: number, rowKeyVal: string | number, rowPinned: boolean)}
|
|
480
|
+
{@const selected = isRowSelected(row, absIdx)}
|
|
481
|
+
{@const expanded = isRowExpanded(rowKeyVal)}
|
|
482
|
+
<tr
|
|
483
|
+
class={classes.tr}
|
|
484
|
+
data-selected={selected || undefined}
|
|
485
|
+
data-selectable={isSelectable || undefined}
|
|
486
|
+
data-expanded={expanded || undefined}
|
|
487
|
+
data-pinned-row={rowPinned || undefined}
|
|
488
|
+
aria-selected={selection ? selected : undefined}
|
|
489
|
+
tabindex={onRowClick || selection ? 0 : undefined}
|
|
490
|
+
onclick={isSelectable ? (e) => handleRowClick(e, row, absIdx) : undefined}
|
|
491
|
+
onkeydown={onRowClick || selection ? (e) => handleRowKeyDown(e, row, absIdx) : undefined}
|
|
492
|
+
onpointerenter={onRowHover ? (e) => handleRowPointerEnter(e, row, absIdx) : undefined}
|
|
493
|
+
onpointerleave={onRowHover ? (e) => handleRowPointerLeave(e, row, absIdx) : undefined}
|
|
494
|
+
oncontextmenu={onRowContextmenu ? (e) => handleRowContextmenu(e, row, absIdx) : undefined}
|
|
495
|
+
>
|
|
496
|
+
{#if selection === 'multiple'}
|
|
497
|
+
<td class={classes.td} style="width: 48px">
|
|
498
|
+
<Checkbox
|
|
499
|
+
checked={selected}
|
|
500
|
+
onCheckedChange={() => toggleRowSelect(row, absIdx)}
|
|
501
|
+
size="sm"
|
|
502
|
+
aria-label="Select row {absIdx + 1}"
|
|
503
|
+
/>
|
|
504
|
+
</td>
|
|
505
|
+
{/if}
|
|
506
|
+
|
|
507
|
+
{#each visibleColumns as col, colIdx (col.key)}
|
|
508
|
+
<td
|
|
509
|
+
class="{tdClass(col.key)} {getAlignClass(col.align)} {col.cellClass ?? ''}"
|
|
510
|
+
data-pinned={isPinned(col.key) || undefined}
|
|
511
|
+
style={getPinStyle(col.key)}
|
|
512
|
+
colspan={col.cellColspan}
|
|
513
|
+
rowspan={col.cellRowspan}
|
|
514
|
+
>
|
|
515
|
+
{#if col.cell}
|
|
516
|
+
{@render col.cell({
|
|
517
|
+
row,
|
|
518
|
+
column: col,
|
|
519
|
+
rowIndex: absIdx,
|
|
520
|
+
columnIndex: colIdx,
|
|
521
|
+
value: row[col.key]
|
|
522
|
+
})}
|
|
523
|
+
{:else if cellSlot}
|
|
524
|
+
{@render cellSlot({
|
|
525
|
+
row,
|
|
526
|
+
column: col,
|
|
527
|
+
rowIndex: absIdx,
|
|
528
|
+
columnIndex: colIdx,
|
|
529
|
+
value: row[col.key]
|
|
530
|
+
})}
|
|
531
|
+
{:else}
|
|
532
|
+
{formatCellValue(row[col.key])}
|
|
533
|
+
{/if}
|
|
534
|
+
</td>
|
|
535
|
+
{/each}
|
|
536
|
+
</tr>
|
|
537
|
+
|
|
538
|
+
{#if expanded && expandedSlot}
|
|
539
|
+
<tr class={classes.tr}>
|
|
540
|
+
<td class={classes.td} colspan={totalColspan}>
|
|
541
|
+
{@render expandedSlot({ row, rowIndex: absIdx })}
|
|
542
|
+
</td>
|
|
543
|
+
</tr>
|
|
544
|
+
{/if}
|
|
545
|
+
{/snippet}
|
|
546
|
+
|
|
547
|
+
<svelte:element
|
|
548
|
+
this={as}
|
|
549
|
+
bind:this={ref}
|
|
550
|
+
class={classes.root}
|
|
551
|
+
role="region"
|
|
552
|
+
aria-label={caption ?? 'Data table'}
|
|
553
|
+
{...restProps}
|
|
554
|
+
>
|
|
555
|
+
<table class={classes.base}>
|
|
556
|
+
{#if captionSlot}
|
|
557
|
+
<caption class={classes.caption}>
|
|
558
|
+
{@render captionSlot()}
|
|
559
|
+
</caption>
|
|
560
|
+
{:else if caption}
|
|
561
|
+
<caption class={classes.caption}>{caption}</caption>
|
|
562
|
+
{/if}
|
|
563
|
+
|
|
564
|
+
{#if visibleColumns.length > 0}
|
|
565
|
+
<colgroup>
|
|
566
|
+
{#if selection === 'multiple'}
|
|
567
|
+
<col style="width: 48px" />
|
|
568
|
+
{/if}
|
|
569
|
+
{#each visibleColumns as col (col.key)}
|
|
570
|
+
<col style:width={getColWidth(col)} />
|
|
571
|
+
{/each}
|
|
572
|
+
</colgroup>
|
|
573
|
+
{/if}
|
|
574
|
+
|
|
575
|
+
<!-- THEAD -->
|
|
576
|
+
<thead class={classes.thead}>
|
|
577
|
+
<tr class={classes.tr}>
|
|
578
|
+
{#if selection === 'multiple'}
|
|
579
|
+
<th
|
|
580
|
+
class={classes.th}
|
|
581
|
+
scope="col"
|
|
582
|
+
style="width: 48px"
|
|
583
|
+
aria-label="Select all rows"
|
|
584
|
+
>
|
|
585
|
+
<Checkbox
|
|
586
|
+
checked={allVisibleSelected}
|
|
587
|
+
indeterminate={someVisibleSelected}
|
|
588
|
+
onCheckedChange={toggleSelectAll}
|
|
589
|
+
size="sm"
|
|
590
|
+
/>
|
|
591
|
+
</th>
|
|
592
|
+
{/if}
|
|
593
|
+
|
|
594
|
+
{#each visibleColumns as col, colIdx (col.key)}
|
|
595
|
+
{@const sortDir = getSortDirection(col.key)}
|
|
596
|
+
{@const sortable = col.sortable === true}
|
|
597
|
+
<th
|
|
598
|
+
class="{thClass(col.key)} {getAlignClass(col.align)} {col.headerClass ??
|
|
599
|
+
''}"
|
|
600
|
+
scope="col"
|
|
601
|
+
data-pinned={isPinned(col.key) || undefined}
|
|
602
|
+
style="{getPinStyle(col.key)}{getColWidth(col)
|
|
603
|
+
? `; width: ${getColWidth(col)}`
|
|
604
|
+
: ''}"
|
|
605
|
+
aria-sort={sortDir === 'asc'
|
|
606
|
+
? 'ascending'
|
|
607
|
+
: sortDir === 'desc'
|
|
608
|
+
? 'descending'
|
|
609
|
+
: undefined}
|
|
610
|
+
colspan={col.colspan}
|
|
611
|
+
rowspan={col.rowspan}
|
|
612
|
+
>
|
|
613
|
+
{#if col.header}
|
|
614
|
+
{@render col.header({
|
|
615
|
+
column: col,
|
|
616
|
+
columnIndex: colIdx,
|
|
617
|
+
sortDirection: sortDir,
|
|
618
|
+
toggleSort: () => toggleSort(col.key)
|
|
619
|
+
})}
|
|
620
|
+
{:else if headerSlot}
|
|
621
|
+
{@render headerSlot({
|
|
622
|
+
column: col,
|
|
623
|
+
columnIndex: colIdx,
|
|
624
|
+
sortDirection: sortDir,
|
|
625
|
+
toggleSort: () => toggleSort(col.key)
|
|
626
|
+
})}
|
|
627
|
+
{:else if sortable}
|
|
628
|
+
<Button
|
|
629
|
+
variant="ghost"
|
|
630
|
+
color="surface"
|
|
631
|
+
size="xs"
|
|
632
|
+
label={col.label ?? col.key}
|
|
633
|
+
trailingIcon={sortDir === 'asc'
|
|
634
|
+
? icons.sortAsc
|
|
635
|
+
: sortDir === 'desc'
|
|
636
|
+
? icons.sortDesc
|
|
637
|
+
: icons.sortDefault}
|
|
638
|
+
onclick={(e) => toggleSort(col.key, e)}
|
|
639
|
+
aria-label="Sort by {col.label ?? col.key}"
|
|
640
|
+
class="-ms-2 font-semibold tracking-wider uppercase {sortDir
|
|
641
|
+
? ''
|
|
642
|
+
: '*:last:opacity-30'}"
|
|
643
|
+
/>
|
|
644
|
+
{:else}
|
|
645
|
+
{col.label ?? col.key}
|
|
646
|
+
{/if}
|
|
647
|
+
|
|
648
|
+
{#if col.resizable}
|
|
649
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
650
|
+
<span
|
|
651
|
+
class="group/resize absolute top-0 -right-px flex h-full w-4 cursor-col-resize touch-none items-center justify-center select-none"
|
|
652
|
+
onmousedown={(e) => onResizeStart(e, col)}
|
|
653
|
+
>
|
|
654
|
+
<span
|
|
655
|
+
class="h-4 w-0.5 rounded-full bg-outline-variant/50 transition-all group-hover/resize:h-5 group-hover/resize:bg-primary group-active/resize:bg-primary"
|
|
656
|
+
></span>
|
|
657
|
+
</span>
|
|
658
|
+
{/if}
|
|
659
|
+
</th>
|
|
660
|
+
{/each}
|
|
661
|
+
</tr>
|
|
662
|
+
</thead>
|
|
663
|
+
|
|
664
|
+
<!-- TBODY -->
|
|
665
|
+
<tbody class={classes.tbody}>
|
|
666
|
+
{#if bodyTopSlot}
|
|
667
|
+
{@render bodyTopSlot()}
|
|
668
|
+
{/if}
|
|
669
|
+
|
|
670
|
+
{#if hasVisibleRows}
|
|
671
|
+
<!-- Pinned rows at top -->
|
|
672
|
+
{#each pinnedData as row, rowIdx (getRowKey(row, rowKey, rowIdx))}
|
|
673
|
+
{@const rowKeyVal = getRowKey(row, rowKey, rowIdx)}
|
|
674
|
+
{@render tableRow(row, rowIdx, rowKeyVal, true)}
|
|
675
|
+
{/each}
|
|
676
|
+
|
|
677
|
+
<!-- Separator between pinned and unpinned -->
|
|
678
|
+
{#if pinnedData.length > 0 && unpinnedPaginatedData.length > 0}
|
|
679
|
+
<tr><td colspan={totalColspan} class="h-0.5 bg-primary/20 p-0"></td></tr>
|
|
680
|
+
{/if}
|
|
681
|
+
|
|
682
|
+
<!-- Regular rows -->
|
|
683
|
+
{#each unpinnedPaginatedData as row, rowIdx (getRowKey(row, rowKey, page * pageSize + rowIdx))}
|
|
684
|
+
{@const absIdx = manualPagination ? rowIdx : page * pageSize + rowIdx}
|
|
685
|
+
{@const rowKeyVal = getRowKey(row, rowKey, absIdx)}
|
|
686
|
+
{@render tableRow(row, absIdx, rowKeyVal, false)}
|
|
687
|
+
{/each}
|
|
688
|
+
{:else if loading && loadingSlot}
|
|
689
|
+
<tr>
|
|
690
|
+
<td colspan={totalColspan} class={classes.loading}>
|
|
691
|
+
{@render loadingSlot()}
|
|
692
|
+
</td>
|
|
693
|
+
</tr>
|
|
694
|
+
{:else}
|
|
695
|
+
<tr>
|
|
696
|
+
<td colspan={totalColspan} class={classes.empty}>
|
|
697
|
+
{#if emptySlot}
|
|
698
|
+
{@render emptySlot()}
|
|
699
|
+
{:else}
|
|
700
|
+
{empty}
|
|
701
|
+
{/if}
|
|
702
|
+
</td>
|
|
703
|
+
</tr>
|
|
704
|
+
{/if}
|
|
705
|
+
|
|
706
|
+
{#if bodyBottomSlot}
|
|
707
|
+
{@render bodyBottomSlot()}
|
|
708
|
+
{/if}
|
|
709
|
+
</tbody>
|
|
710
|
+
|
|
711
|
+
<!-- TFOOT -->
|
|
712
|
+
{#if hasFooter}
|
|
713
|
+
<tfoot class={classes.tfoot}>
|
|
714
|
+
<tr class={classes.tr}>
|
|
715
|
+
{#if selection === 'multiple'}
|
|
716
|
+
<th class={classes.th}></th>
|
|
717
|
+
{/if}
|
|
718
|
+
|
|
719
|
+
{#each visibleColumns as col, colIdx (col.key)}
|
|
720
|
+
<th
|
|
721
|
+
class="{thClass(col.key)} {getAlignClass(col.align)}"
|
|
722
|
+
data-pinned={isPinned(col.key) || undefined}
|
|
723
|
+
style={getPinStyle(col.key)}
|
|
724
|
+
>
|
|
725
|
+
{#if col.footer}
|
|
726
|
+
{@render col.footer({
|
|
727
|
+
column: col,
|
|
728
|
+
columnIndex: colIdx,
|
|
729
|
+
rows: sortedData
|
|
730
|
+
})}
|
|
731
|
+
{/if}
|
|
732
|
+
</th>
|
|
733
|
+
{/each}
|
|
734
|
+
</tr>
|
|
735
|
+
</tfoot>
|
|
736
|
+
{/if}
|
|
737
|
+
</table>
|
|
738
|
+
|
|
739
|
+
{#if loading && hasVisibleRows}
|
|
740
|
+
<div
|
|
741
|
+
class="absolute inset-0 z-20 flex items-center justify-center rounded-xl bg-surface/60 backdrop-blur-[1px] transition-opacity duration-200"
|
|
742
|
+
role="status"
|
|
743
|
+
aria-label="Loading"
|
|
744
|
+
>
|
|
745
|
+
{#if loadingSlot}
|
|
746
|
+
{@render loadingSlot()}
|
|
747
|
+
{:else}
|
|
748
|
+
<Icon name={icons.loading} class="size-6 animate-spin text-primary" />
|
|
749
|
+
{/if}
|
|
750
|
+
</div>
|
|
751
|
+
{/if}
|
|
752
|
+
</svelte:element>
|