sv5ui 1.4.0 → 1.5.1

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