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.
Files changed (76) hide show
  1. package/README.md +16 -11
  2. package/dist/Checkbox/Checkbox.svelte +2 -11
  3. package/dist/CheckboxGroup/CheckboxGroup.svelte +2 -11
  4. package/dist/Collapsible/Collapsible.svelte +69 -0
  5. package/dist/Collapsible/Collapsible.svelte.d.ts +6 -0
  6. package/dist/Collapsible/CollapsibleTestWrapper.svelte +17 -0
  7. package/dist/Collapsible/CollapsibleTestWrapper.svelte.d.ts +4 -0
  8. package/dist/Collapsible/collapsible.types.d.ts +75 -0
  9. package/dist/Collapsible/collapsible.types.js +1 -0
  10. package/dist/Collapsible/collapsible.variants.d.ts +53 -0
  11. package/dist/Collapsible/collapsible.variants.js +21 -0
  12. package/dist/Collapsible/index.d.ts +2 -0
  13. package/dist/Collapsible/index.js +1 -0
  14. package/dist/Command/Command.svelte +183 -0
  15. package/dist/Command/Command.svelte.d.ts +6 -0
  16. package/dist/Command/CommandTestWrapper.svelte +13 -0
  17. package/dist/Command/CommandTestWrapper.svelte.d.ts +4 -0
  18. package/dist/Command/command.types.d.ts +98 -0
  19. package/dist/Command/command.types.js +1 -0
  20. package/dist/Command/command.variants.d.ts +226 -0
  21. package/dist/Command/command.variants.js +86 -0
  22. package/dist/Command/index.d.ts +2 -0
  23. package/dist/Command/index.js +1 -0
  24. package/dist/FormField/FormField.svelte +2 -6
  25. package/dist/Input/Input.svelte +2 -10
  26. package/dist/PinInput/PinInput.svelte +2 -11
  27. package/dist/RadioGroup/RadioGroup.svelte +2 -11
  28. package/dist/Select/Select.svelte +2 -10
  29. package/dist/Select/select.variants.js +1 -1
  30. package/dist/SelectMenu/SelectMenu.svelte +2 -10
  31. package/dist/SelectMenu/select-menu.variants.js +1 -1
  32. package/dist/Slider/Slider.svelte +2 -11
  33. package/dist/Switch/Switch.svelte +2 -11
  34. package/dist/Table/Table.svelte +752 -0
  35. package/dist/Table/Table.svelte.d.ts +26 -0
  36. package/dist/Table/index.d.ts +2 -0
  37. package/dist/Table/index.js +1 -0
  38. package/dist/Table/table.types.d.ts +199 -0
  39. package/dist/Table/table.types.js +1 -0
  40. package/dist/Table/table.utils.d.ts +51 -0
  41. package/dist/Table/table.utils.js +166 -0
  42. package/dist/Table/table.variants.d.ts +205 -0
  43. package/dist/Table/table.variants.js +126 -0
  44. package/dist/Textarea/Textarea.svelte +2 -10
  45. package/dist/Toast/Toaster.svelte +618 -0
  46. package/dist/Toast/Toaster.svelte.d.ts +5 -0
  47. package/dist/Toast/index.d.ts +4 -0
  48. package/dist/Toast/index.js +2 -0
  49. package/dist/Toast/toast.d.ts +38 -0
  50. package/dist/Toast/toast.js +73 -0
  51. package/dist/Toast/toast.types.d.ts +19 -0
  52. package/dist/Toast/toast.types.js +1 -0
  53. package/dist/Toast/toast.variants.d.ts +7 -0
  54. package/dist/Toast/toast.variants.js +5 -0
  55. package/dist/config.d.ts +4 -0
  56. package/dist/config.js +5 -1
  57. package/dist/hooks/index.d.ts +14 -0
  58. package/dist/hooks/index.js +7 -0
  59. package/dist/hooks/useClickOutside.svelte.d.ts +31 -0
  60. package/dist/hooks/useClickOutside.svelte.js +37 -0
  61. package/dist/hooks/useClipboard.svelte.d.ts +30 -0
  62. package/dist/hooks/useClipboard.svelte.js +45 -0
  63. package/dist/hooks/useDebounce.svelte.d.ts +36 -0
  64. package/dist/hooks/useDebounce.svelte.js +56 -0
  65. package/dist/hooks/useEscapeKeydown.svelte.d.ts +31 -0
  66. package/dist/hooks/useEscapeKeydown.svelte.js +37 -0
  67. package/dist/hooks/useFormField.svelte.d.ts +21 -0
  68. package/dist/hooks/useFormField.svelte.js +17 -0
  69. package/dist/hooks/useInfiniteScroll.svelte.d.ts +57 -0
  70. package/dist/hooks/useInfiniteScroll.svelte.js +69 -0
  71. package/dist/hooks/useMediaQuery.svelte.d.ts +31 -0
  72. package/dist/hooks/useMediaQuery.svelte.js +38 -0
  73. package/dist/index.d.ts +5 -0
  74. package/dist/index.js +6 -0
  75. package/dist/theme.css +36 -0
  76. 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>