@makolabs/ripple 1.7.10 → 1.8.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 (90) hide show
  1. package/dist/adapters/ai/OpenAIAdapter.d.ts +8 -1
  2. package/dist/adapters/ai/OpenAIAdapter.js +2 -2
  3. package/dist/adapters/storage/BaseAdapter.js +2 -2
  4. package/dist/adapters/storage/S3Adapter.js +1 -6
  5. package/dist/adapters/storage/types.d.ts +3 -3
  6. package/dist/ai/AIChatInterface.svelte +0 -1
  7. package/dist/ai/ai-chat-interface.d.ts +21 -22
  8. package/dist/ai/content-detector.js +0 -1
  9. package/dist/button/Button.svelte +9 -2
  10. package/dist/button/button.d.ts +39 -40
  11. package/dist/charts/Chart.svelte +4 -1
  12. package/dist/drawer/Drawer.svelte +57 -23
  13. package/dist/drawer/drawer.d.ts +18 -19
  14. package/dist/elements/accordion/Accordion.svelte +39 -18
  15. package/dist/elements/accordion/accordion.d.ts +21 -22
  16. package/dist/elements/alert/Alert.svelte +20 -8
  17. package/dist/elements/badge/Badge.svelte +5 -2
  18. package/dist/elements/badge/badge.d.ts +39 -40
  19. package/dist/elements/dropdown/Dropdown.svelte +18 -2
  20. package/dist/elements/dropdown/Select.svelte +17 -5
  21. package/dist/elements/dropdown/dropdown.d.ts +18 -19
  22. package/dist/elements/dropdown/select.d.ts +18 -19
  23. package/dist/elements/pagination/Pagination.svelte +15 -2
  24. package/dist/elements/pagination/Pagination.svelte.d.ts +1 -0
  25. package/dist/forms/Checkbox.svelte +16 -4
  26. package/dist/forms/Form.svelte +0 -2
  27. package/dist/forms/Input.svelte +16 -4
  28. package/dist/forms/NumberInput.svelte +8 -1
  29. package/dist/forms/RadioInputs.svelte +14 -5
  30. package/dist/forms/Slider.svelte +6 -4
  31. package/dist/forms/Toggle.svelte +67 -29
  32. package/dist/forms/slider.d.ts +72 -10
  33. package/dist/forms/slider.js +21 -0
  34. package/dist/header/Breadcrumbs.svelte +47 -24
  35. package/dist/header/PageHeader.svelte +12 -2
  36. package/dist/header/breadcrumbs.d.ts +47 -39
  37. package/dist/helper/deprecation.d.ts +14 -0
  38. package/dist/helper/deprecation.js +24 -0
  39. package/dist/helper/testid.d.ts +10 -0
  40. package/dist/helper/testid.js +17 -0
  41. package/dist/index.d.ts +147 -47
  42. package/dist/index.js +1 -0
  43. package/dist/layout/activity-list/activity-list.d.ts +21 -22
  44. package/dist/layout/card/Card.svelte +19 -5
  45. package/dist/layout/card/card.d.ts +21 -22
  46. package/dist/layout/card/ranked-card.d.ts +2 -1
  47. package/dist/layout/navbar/Navbar.svelte +14 -16
  48. package/dist/layout/navbar/navbar.d.ts +19 -19
  49. package/dist/layout/sidebar/Sidebar.svelte +6 -3
  50. package/dist/layout/table/Table.svelte +237 -303
  51. package/dist/layout/table/table.d.ts +24 -25
  52. package/dist/layout/tabs/Tab.svelte +3 -1
  53. package/dist/layout/tabs/TabGroup.svelte +7 -4
  54. package/dist/layout/tabs/tabs.d.ts +39 -40
  55. package/dist/modal/Modal.svelte +124 -21
  56. package/dist/modal/modal.d.ts +18 -19
  57. package/dist/modal/modal.js +2 -2
  58. package/dist/user-management/UserModal.svelte +1 -1
  59. package/dist/user-management/UserTable.svelte +3 -3
  60. package/dist/user-management/UserViewModal.svelte +2 -2
  61. package/dist/variants.d.ts +13 -13
  62. package/package.json +9 -15
  63. package/dist/ai/AIChatInterfaceTestWrapper.svelte +0 -26
  64. package/dist/ai/AIChatInterfaceTestWrapper.svelte.d.ts +0 -17
  65. package/dist/button/ButtonTestWrapper.svelte +0 -10
  66. package/dist/button/ButtonTestWrapper.svelte.d.ts +0 -7
  67. package/dist/drawer/DrawerTestWrapper.svelte +0 -19
  68. package/dist/drawer/DrawerTestWrapper.svelte.d.ts +0 -9
  69. package/dist/elements/accordion/AccordionTestWrapper.svelte +0 -21
  70. package/dist/elements/accordion/AccordionTestWrapper.svelte.d.ts +0 -10
  71. package/dist/elements/badge/BadgeTestWrapper.svelte +0 -14
  72. package/dist/elements/badge/BadgeTestWrapper.svelte.d.ts +0 -9
  73. package/dist/forms/CheckboxTestWrapper.svelte +0 -8
  74. package/dist/forms/CheckboxTestWrapper.svelte.d.ts +0 -4
  75. package/dist/forms/InputTestWrapper.svelte +0 -8
  76. package/dist/forms/InputTestWrapper.svelte.d.ts +0 -4
  77. package/dist/forms/ToggleTestWrapper.svelte +0 -8
  78. package/dist/forms/ToggleTestWrapper.svelte.d.ts +0 -7
  79. package/dist/layout/card/CardTestWrapper.svelte +0 -15
  80. package/dist/layout/card/CardTestWrapper.svelte.d.ts +0 -7
  81. package/dist/modal/ModalTestWrapper.svelte +0 -20
  82. package/dist/modal/ModalTestWrapper.svelte.d.ts +0 -8
  83. package/dist/user-management/UserManagementTestWrapper.svelte +0 -32
  84. package/dist/user-management/UserManagementTestWrapper.svelte.d.ts +0 -12
  85. package/dist/user-management/UserModalTestWrapper.svelte +0 -22
  86. package/dist/user-management/UserModalTestWrapper.svelte.d.ts +0 -7
  87. package/dist/user-management/UserTableTestWrapper.svelte +0 -41
  88. package/dist/user-management/UserTableTestWrapper.svelte.d.ts +0 -7
  89. package/dist/user-management/UserViewModalTestWrapper.svelte +0 -22
  90. package/dist/user-management/UserViewModalTestWrapper.svelte.d.ts +0 -7
@@ -1,9 +1,12 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../../helper/cls.js';
3
3
  import { table } from './table.js';
4
+ import { buildTestId } from '../../helper/testid.js';
5
+ import { warnDeprecatedProps } from '../../helper/deprecation.js';
4
6
  import type { TableProps, SortDirection, SortState, DataRow } from '../../index.js';
5
7
  import Pagination from '../../elements/pagination/Pagination.svelte';
6
8
  import Card from '../../layout/card/Card.svelte';
9
+ import { SvelteSet } from 'svelte/reactivity';
7
10
 
8
11
  let {
9
12
  data = [],
@@ -16,21 +19,31 @@
16
19
  selectable = false,
17
20
  selected = $bindable([]),
18
21
  onrowclick,
19
- onsort = () => {},
22
+ onsort,
20
23
  onselect = () => {},
21
24
  onpagechange,
22
25
  onpagesizechange,
23
26
  class: classname = '',
24
- wrapperclass: wrapperClass = '',
25
- tableclass: tableClass = '',
26
- theadclass: theadClass = '',
27
- tbodyclass: tbodyClass = '',
28
- trclass: trClass = 'bg-white',
29
- thclass: thClass = '',
30
- tdclass: tdClass = '',
31
- footerclass: footerClass = '',
32
- paginationclass: paginationClass = '',
33
- rowclass = () => '',
27
+ wrapperclass,
28
+ wrapperClass = wrapperclass ?? '',
29
+ tableclass,
30
+ tableClass = tableclass ?? '',
31
+ theadclass,
32
+ theadClass = theadclass ?? '',
33
+ tbodyclass,
34
+ tbodyClass = tbodyclass ?? '',
35
+ trclass,
36
+ trClass = trclass ?? 'bg-white',
37
+ thclass,
38
+ thClass = thclass ?? '',
39
+ tdclass,
40
+ tdClass = tdclass ?? '',
41
+ footerclass,
42
+ footerClass = footerclass ?? '',
43
+ paginationclass,
44
+ paginationClass = paginationclass ?? '',
45
+ rowclass,
46
+ rowClass = rowclass ?? (() => ''),
34
47
  loading = false,
35
48
  expandedContent,
36
49
  pagination = true,
@@ -41,9 +54,42 @@
41
54
  paginationTemplate = 'full',
42
55
  title,
43
56
  subtitle,
44
- headerActions
57
+ headerActions,
58
+ selectAllScope = 'page',
59
+ rowKey,
60
+ expandable = false,
61
+ testId
45
62
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
46
63
  }: TableProps<any> = $props();
64
+
65
+ warnDeprecatedProps(
66
+ 'Table',
67
+ {
68
+ wrapperclass,
69
+ tableclass,
70
+ theadclass,
71
+ tbodyclass,
72
+ trclass,
73
+ thclass,
74
+ tdclass,
75
+ footerclass,
76
+ paginationclass,
77
+ rowclass
78
+ },
79
+ {
80
+ wrapperclass: 'wrapperClass',
81
+ tableclass: 'tableClass',
82
+ theadclass: 'theadClass',
83
+ tbodyclass: 'tbodyClass',
84
+ trclass: 'trClass',
85
+ thclass: 'thClass',
86
+ tdclass: 'tdClass',
87
+ footerclass: 'footerClass',
88
+ paginationclass: 'paginationClass',
89
+ rowclass: 'rowClass'
90
+ }
91
+ );
92
+
47
93
  // Determine if we should use Card wrapper
48
94
  const hasHeader = $derived(title !== undefined || subtitle !== undefined);
49
95
 
@@ -58,6 +104,7 @@
58
104
  let sortDirection = $state<SortDirection>(null);
59
105
  let internalCurrentPage = $state(externalCurrentPage || 1);
60
106
  let internalPageSize = $state(pageSize);
107
+ let expandedRows = new SvelteSet<string | number>();
61
108
 
62
109
  // Use external current page if provided
63
110
  $effect(() => {
@@ -109,24 +156,52 @@
109
156
  const paginationClasses = $derived(cn(paginationBaseClass(), paginationClass));
110
157
  const emptyStateClasses = $derived(emptyStateBaseClass());
111
158
 
159
+ // Apply client-side sorting when no onsort handler is provided
160
+ const sortedData = $derived.by<DataRow[]>(() => {
161
+ if (onsort || !sortColumn || !sortDirection) {
162
+ return data;
163
+ }
164
+
165
+ return [...data].sort((a, b) => {
166
+ const aVal = a[sortColumn];
167
+ const bVal = b[sortColumn];
168
+
169
+ // Handle null/undefined
170
+ if (aVal == null && bVal == null) return 0;
171
+ if (aVal == null) return sortDirection === 'asc' ? -1 : 1;
172
+ if (bVal == null) return sortDirection === 'asc' ? 1 : -1;
173
+
174
+ // Numeric comparison
175
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
176
+ return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
177
+ }
178
+
179
+ // String comparison
180
+ const aStr = String(aVal);
181
+ const bStr = String(bVal);
182
+ const cmp = aStr.localeCompare(bStr);
183
+ return sortDirection === 'asc' ? cmp : -cmp;
184
+ });
185
+ });
186
+
112
187
  // Handle pagination
113
188
  function getPaginatedData() {
114
189
  // If no pagination or all data fits on one page, return all data
115
- if (!pagination || data.length <= internalPageSize) {
116
- return data;
190
+ if (!pagination || sortedData.length <= internalPageSize) {
191
+ return sortedData;
117
192
  }
118
193
 
119
194
  // If external data source might be handling pagination
120
195
  if (totalItems !== undefined) {
121
196
  // If data.length is less than or equal to pageSize, assume it's already paginated
122
- if (data.length <= internalPageSize) {
123
- return data;
197
+ if (sortedData.length <= internalPageSize) {
198
+ return sortedData;
124
199
  }
125
200
  }
126
201
 
127
202
  // Otherwise, handle pagination internally
128
203
  const startIndex = (internalCurrentPage - 1) * internalPageSize;
129
- return data.slice(startIndex, startIndex + internalPageSize);
204
+ return sortedData.slice(startIndex, startIndex + internalPageSize);
130
205
  }
131
206
 
132
207
  // Handle sorting
@@ -150,26 +225,86 @@
150
225
  }
151
226
 
152
227
  const newSortState: SortState = { column: sortColumn, direction: sortDirection };
153
- onsort(newSortState);
228
+ onsort?.(newSortState);
154
229
  }
155
230
 
156
231
  function toggleRowSelection(row: DataRow) {
157
232
  if (!selectable) return;
158
233
 
159
- const index = selected.findIndex((r) => r === row);
234
+ const index = rowKey
235
+ ? selected.findIndex((r) => r[rowKey] === row[rowKey])
236
+ : selected.findIndex((r) => r === row);
160
237
  if (index === -1) {
161
238
  selected = [...selected, row];
162
239
  } else {
163
- selected = selected.filter((r) => r !== row);
240
+ selected = rowKey
241
+ ? selected.filter((r) => r[rowKey] !== row[rowKey])
242
+ : selected.filter((r) => r !== row);
164
243
  }
165
244
 
166
245
  onselect(selected);
167
246
  }
168
247
 
169
248
  function isRowSelected(row: DataRow) {
249
+ if (rowKey) return selected.some((r) => r[rowKey] === row[rowKey]);
170
250
  return selected.includes(row);
171
251
  }
172
252
 
253
+ function getRowExpandKey(row: DataRow, index: number): string | number {
254
+ return rowKey ? row[rowKey] : index;
255
+ }
256
+
257
+ function toggleRowExpanded(row: DataRow, index: number) {
258
+ const key = getRowExpandKey(row, index);
259
+ if (expandedRows.has(key)) {
260
+ expandedRows.delete(key);
261
+ } else {
262
+ expandedRows.add(key);
263
+ }
264
+ }
265
+
266
+ function isRowExpanded(row: DataRow, index: number): boolean {
267
+ return expandedRows.has(getRowExpandKey(row, index));
268
+ }
269
+
270
+ // Total column count including selectable checkbox and expandable chevron columns
271
+ const totalColumnCount = $derived(
272
+ columns.length + (selectable ? 1 : 0) + (expandable && expandedContent ? 1 : 0)
273
+ );
274
+
275
+ function handleSelectAll() {
276
+ const pageData = getPaginatedData();
277
+ const scopeData = selectAllScope === 'all' ? data : pageData;
278
+ const allSelected = scopeData.every((r) => isRowSelected(r));
279
+ if (allSelected) {
280
+ if (selectAllScope === 'all') {
281
+ selected = [];
282
+ } else {
283
+ selected = selected.filter(
284
+ (r) => !pageData.some((pr) => (rowKey ? pr[rowKey] === r[rowKey] : pr === r))
285
+ );
286
+ }
287
+ } else {
288
+ if (selectAllScope === 'all') {
289
+ selected = [...data];
290
+ } else {
291
+ const newSelected = [...selected];
292
+ for (const r of pageData) {
293
+ if (!isRowSelected(r)) {
294
+ newSelected.push(r);
295
+ }
296
+ }
297
+ selected = newSelected;
298
+ }
299
+ }
300
+ onselect(selected);
301
+ }
302
+
303
+ function isAllSelected(): boolean {
304
+ const scopeData = selectAllScope === 'all' ? data : getPaginatedData();
305
+ return scopeData.length > 0 && scopeData.every((r) => isRowSelected(r));
306
+ }
307
+
173
308
  function handleRowClick(row: DataRow, index: number) {
174
309
  onrowclick?.(row, index);
175
310
  }
@@ -193,263 +328,7 @@
193
328
  }
194
329
  </script>
195
330
 
196
- {#if hasHeader}
197
- <Card>
198
- {#snippet custom()}
199
- <!-- Header Section -->
200
- <div class="border-default-200 mb-4 border-b pb-3">
201
- <div class="flex items-center justify-between">
202
- <div>
203
- {#if title}
204
- <h2 class="text-default-900 text-lg font-semibold">{title}</h2>
205
- {/if}
206
- {#if subtitle}
207
- <p class="text-default-500 mt-1 text-xs">{subtitle}</p>
208
- {/if}
209
- </div>
210
- {#if headerActions}
211
- <div class="flex items-center">
212
- {@render headerActions()}
213
- </div>
214
- {/if}
215
- </div>
216
- </div>
217
-
218
- <!-- Table Content -->
219
- <div class={baseClasses}>
220
- {#if showPaginationControls && (paginationPosition === 'top' || paginationPosition === 'both')}
221
- <div class={footerClasses}>
222
- <Pagination
223
- currentPage={internalCurrentPage}
224
- totalItems={effectiveTotalItems}
225
- pageSize={internalPageSize}
226
- onPageChange={handlePageChange}
227
- onPageSizeChange={handlePageSizeChange}
228
- {showPageSize}
229
- {pageSizeOptions}
230
- template={paginationTemplate === 'full' ? 'full' : 'compact'}
231
- disabled={loading}
232
- class={cn(paginationClasses, paginationClass)}
233
- />
234
- </div>
235
- {/if}
236
-
237
- <div class={wrapperClasses}>
238
- <table class={tableClasses}>
239
- <thead class={theadClasses}>
240
- <tr>
241
- {#if selectable}
242
- <th class={cn(thClasses, 'text-center')}>
243
- <input
244
- type="checkbox"
245
- onchange={() => {
246
- if (selected.length === data.length) {
247
- selected = [];
248
- } else {
249
- selected = [...data];
250
- }
251
- onselect(selected);
252
- }}
253
- checked={selected.length === data.length && data.length > 0}
254
- aria-label="Select all rows"
255
- />
256
- </th>
257
- {/if}
258
-
259
- {#each columns as column (column.key)}
260
- <th
261
- class={cn(
262
- thClasses,
263
- column.align === 'center' && 'text-center',
264
- column.align === 'right' && 'text-right',
265
- column.class
266
- )}
267
- style={column.width ? `width: ${column.width}` : undefined}
268
- >
269
- {#if column.sortable}
270
- <button
271
- type="button"
272
- class={sortButtonBaseClass()}
273
- onclick={() => toggleSort(column.sortKey || column.key)}
274
- aria-label={`Sort by ${column.header}`}
275
- >
276
- {column.header}
277
- <span class={sortIconBaseClass()}>
278
- {#if sortColumn === (column.sortKey || column.key)}
279
- {#if sortDirection === 'asc'}
280
- <svg
281
- xmlns="http://www.w3.org/2000/svg"
282
- viewBox="0 0 20 20"
283
- fill="currentColor"
284
- class="h-4 w-4"
285
- >
286
- <path
287
- d="M10 15a.75.75 0 01-.75-.75V7.612L6.058 10.8a.75.75 0 01-1.061-1.061l3.75-3.75a.75.75 0 011.06 0l3.75 3.75a.75.75 0 11-1.06 1.061L10.75 7.612v6.638A.75.75 0 0110 15z"
288
- />
289
- </svg>
290
- {:else if sortDirection === 'desc'}
291
- <svg
292
- xmlns="http://www.w3.org/2000/svg"
293
- viewBox="0 0 20 20"
294
- fill="currentColor"
295
- class="h-4 w-4"
296
- >
297
- <path
298
- d="M10 5a.75.75 0 01.75.75v6.638l3.192-3.187a.75.75 0 111.06 1.061l-3.75 3.75a.75.75 0 01-1.06 0l-3.75-3.75a.75.75 0 111.06-1.061L9.25 12.389V5.75A.75.75 0 0110 5z"
299
- />
300
- </svg>
301
- {/if}
302
- {:else}
303
- <svg
304
- xmlns="http://www.w3.org/2000/svg"
305
- fill="none"
306
- viewBox="0 0 24 24"
307
- stroke-width="1.5"
308
- stroke="currentColor"
309
- class="h-4 w-4 opacity-40"
310
- >
311
- <path
312
- stroke-linecap="round"
313
- stroke-linejoin="round"
314
- d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
315
- />
316
- </svg>
317
- {/if}
318
- </span>
319
- </button>
320
- {:else}
321
- {column.header}
322
- {/if}
323
- </th>
324
- {/each}
325
- </tr>
326
- </thead>
327
-
328
- <tbody class={tbodyClasses}>
329
- {#if loading}
330
- {#each Array.from({ length: Math.min(internalPageSize, 5) }, (__, i) => i) as rowIdx (rowIdx)}
331
- <tr class={cn(trClass, rowIdx % 2 === 1 && striped ? 'bg-default-50' : '')}>
332
- {#if selectable}
333
- <td class={cn(tdClasses, 'text-center')}>
334
- <div class="bg-default-200 mx-auto h-4 w-4 animate-pulse rounded"></div>
335
- </td>
336
- {/if}
337
- {#each columns as column, colIdx (column.key)}
338
- <td class={cn(tdClasses, column.class)}>
339
- {#if colIdx === 0}
340
- <div class="flex items-center gap-3">
341
- <div
342
- class="bg-default-200 h-8 w-8 shrink-0 animate-pulse rounded-full"
343
- ></div>
344
- <div class="flex-1 space-y-2">
345
- <div class="bg-default-200 h-3.5 w-28 animate-pulse rounded"></div>
346
- <div class="bg-default-100 h-3 w-20 animate-pulse rounded"></div>
347
- </div>
348
- </div>
349
- {:else if colIdx === columns.length - 1}
350
- <div class="flex items-center justify-end gap-2">
351
- <div class="bg-default-200 h-5 w-5 animate-pulse rounded"></div>
352
- <div class="bg-default-200 h-5 w-5 animate-pulse rounded"></div>
353
- <div class="bg-default-200 h-5 w-5 animate-pulse rounded"></div>
354
- </div>
355
- {:else}
356
- <div
357
- class="bg-default-200 h-3.5 animate-pulse rounded"
358
- style="width: {60 + ((rowIdx * 17 + colIdx * 31) % 40)}%"
359
- ></div>
360
- {/if}
361
- </td>
362
- {/each}
363
- </tr>
364
- {/each}
365
- {:else if getPaginatedData().length === 0}
366
- <tr>
367
- <td
368
- colspan={selectable ? columns.length + 1 : columns.length}
369
- class={emptyStateClasses}
370
- >
371
- No data available
372
- </td>
373
- </tr>
374
- {:else}
375
- {#each getPaginatedData() as row, rowIndex (rowIndex)}
376
- <tr
377
- class={cn(trClasses, rowclass(row, rowIndex), {
378
- 'bg-primary-100': selectable && isRowSelected(row),
379
- 'cursor-pointer': onrowclick
380
- })}
381
- onclick={() => handleRowClick(row, rowIndex)}
382
- aria-selected={selectable && isRowSelected(row)}
383
- >
384
- {#if selectable}
385
- <td class={cn(tdClasses, 'text-center')}>
386
- <input
387
- type="checkbox"
388
- checked={isRowSelected(row)}
389
- onclick={(e) => {
390
- e.stopPropagation(); // Prevent row click
391
- toggleRowSelection(row);
392
- }}
393
- aria-label={`Select row ${rowIndex + 1}`}
394
- />
395
- </td>
396
- {/if}
397
-
398
- {#each columns as column (column.key)}
399
- <td
400
- class={cn(
401
- tdClasses,
402
- column.align === 'center' && 'text-center',
403
- column.align === 'right' && 'text-right',
404
- column.class
405
- )}
406
- >
407
- {#if column.cell}
408
- {@render column.cell(row, column.key, rowIndex)}
409
- {:else if row[column.key] === undefined || row[column.key] === null}
410
- <span class="text-default-300">—</span>
411
- {:else}
412
- {row[column.key]}
413
- {/if}
414
- </td>
415
- {/each}
416
- </tr>
417
- {#if expandedContent}
418
- <tr class="expandedContent-row">
419
- <td
420
- colspan={selectable ? columns.length + 1 : columns.length}
421
- class="border-0 p-0"
422
- >
423
- {@render expandedContent(row)}
424
- </td>
425
- </tr>
426
- {/if}
427
- {/each}
428
- {/if}
429
- </tbody>
430
- </table>
431
- </div>
432
-
433
- {#if showPaginationControls && (paginationPosition === 'bottom' || paginationPosition === 'both')}
434
- <div class={footerClasses}>
435
- <Pagination
436
- currentPage={internalCurrentPage}
437
- totalItems={effectiveTotalItems}
438
- pageSize={internalPageSize}
439
- onPageChange={handlePageChange}
440
- onPageSizeChange={handlePageSizeChange}
441
- {showPageSize}
442
- {pageSizeOptions}
443
- template={paginationTemplate === 'full' ? 'full' : 'compact'}
444
- disabled={loading}
445
- class={cn(paginationClasses, paginationClass)}
446
- />
447
- </div>
448
- {/if}
449
- </div>
450
- {/snippet}
451
- </Card>
452
- {:else}
331
+ {#snippet tableContent()}
453
332
  <div class={baseClasses}>
454
333
  {#if showPaginationControls && (paginationPosition === 'top' || paginationPosition === 'both')}
455
334
  <div class={footerClasses}>
@@ -469,28 +348,25 @@
469
348
  {/if}
470
349
 
471
350
  <div class={wrapperClasses}>
472
- <table class={tableClasses}>
473
- <thead class={theadClasses}>
351
+ <table class={tableClasses} data-testid={buildTestId('table', undefined, testId)}>
352
+ <thead class={theadClasses} data-testid={buildTestId('table', 'head', testId)}>
474
353
  <tr>
354
+ {#if expandable && expandedContent}
355
+ <th class={cn(thClasses, 'w-10')}></th>
356
+ {/if}
475
357
  {#if selectable}
476
358
  <th class={cn(thClasses, 'text-center')}>
477
359
  <input
478
360
  type="checkbox"
479
- onchange={() => {
480
- if (selected.length === data.length) {
481
- selected = [];
482
- } else {
483
- selected = [...data];
484
- }
485
- onselect(selected);
486
- }}
487
- checked={selected.length === data.length && data.length > 0}
361
+ onchange={handleSelectAll}
362
+ checked={isAllSelected()}
488
363
  aria-label="Select all rows"
364
+ data-testid={buildTestId('table', 'select-all', testId)}
489
365
  />
490
366
  </th>
491
367
  {/if}
492
368
 
493
- {#each columns as column (column.key)}
369
+ {#each columns as column, colIndex (column.key)}
494
370
  <th
495
371
  class={cn(
496
372
  thClasses,
@@ -506,6 +382,7 @@
506
382
  class={sortButtonBaseClass()}
507
383
  onclick={() => toggleSort(column.sortKey || column.key)}
508
384
  aria-label={`Sort by ${column.header}`}
385
+ data-testid={buildTestId('table', 'sort', testId, colIndex)}
509
386
  >
510
387
  {column.header}
511
388
  <span class={sortIconBaseClass()}>
@@ -559,10 +436,15 @@
559
436
  </tr>
560
437
  </thead>
561
438
 
562
- <tbody class={tbodyClasses}>
439
+ <tbody class={tbodyClasses} data-testid={buildTestId('table', 'body', testId)}>
563
440
  {#if loading}
564
441
  {#each Array.from({ length: Math.min(internalPageSize, 5) }, (__, i) => i) as rowIdx (rowIdx)}
565
- <tr class={cn(trClass, rowIdx % 2 === 1 && striped ? 'bg-default-50' : '')}>
442
+ <tr class={cn(trClasses, rowIdx % 2 === 1 && striped ? 'bg-default-50' : '')}>
443
+ {#if expandable && expandedContent}
444
+ <td class={cn(tdClasses, 'w-10')}>
445
+ <div class="bg-default-200 mx-auto h-4 w-4 animate-pulse rounded"></div>
446
+ </td>
447
+ {/if}
566
448
  {#if selectable}
567
449
  <td class={cn(tdClasses, 'text-center')}>
568
450
  <div class="bg-default-200 mx-auto h-4 w-4 animate-pulse rounded"></div>
@@ -598,23 +480,48 @@
598
480
  {/each}
599
481
  {:else if getPaginatedData().length === 0}
600
482
  <tr>
601
- <td
602
- colspan={selectable ? columns.length + 1 : columns.length}
603
- class={emptyStateClasses}
604
- >
605
- No data available
606
- </td>
483
+ <td colspan={totalColumnCount} class={emptyStateClasses}> No data available </td>
607
484
  </tr>
608
485
  {:else}
609
486
  {#each getPaginatedData() as row, rowIndex (rowIndex)}
610
487
  <tr
611
- class={cn(trClasses, rowclass(row, rowIndex), {
488
+ class={cn(trClasses, rowClass(row, rowIndex), {
612
489
  'bg-primary-100': selectable && isRowSelected(row),
613
490
  'cursor-pointer': onrowclick
614
491
  })}
615
492
  onclick={() => handleRowClick(row, rowIndex)}
616
493
  aria-selected={selectable && isRowSelected(row)}
494
+ data-testid={buildTestId('table', 'row', testId, rowIndex)}
617
495
  >
496
+ {#if expandable && expandedContent}
497
+ <td class={cn(tdClasses, 'w-10')}>
498
+ <button
499
+ type="button"
500
+ class="text-default-400 hover:text-default-600 flex items-center justify-center transition-transform"
501
+ onclick={(e) => {
502
+ e.stopPropagation();
503
+ toggleRowExpanded(row, rowIndex);
504
+ }}
505
+ aria-label={isRowExpanded(row, rowIndex) ? 'Collapse row' : 'Expand row'}
506
+ >
507
+ <svg
508
+ xmlns="http://www.w3.org/2000/svg"
509
+ viewBox="0 0 20 20"
510
+ fill="currentColor"
511
+ class={cn(
512
+ 'h-5 w-5 transition-transform',
513
+ isRowExpanded(row, rowIndex) && 'rotate-90'
514
+ )}
515
+ >
516
+ <path
517
+ fill-rule="evenodd"
518
+ d="M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z"
519
+ clip-rule="evenodd"
520
+ />
521
+ </svg>
522
+ </button>
523
+ </td>
524
+ {/if}
618
525
  {#if selectable}
619
526
  <td class={cn(tdClasses, 'text-center')}>
620
527
  <input
@@ -625,6 +532,7 @@
625
532
  toggleRowSelection(row);
626
533
  }}
627
534
  aria-label={`Select row ${rowIndex + 1}`}
535
+ data-testid={buildTestId('table', 'row-select', testId, rowIndex)}
628
536
  />
629
537
  </td>
630
538
  {/if}
@@ -648,12 +556,9 @@
648
556
  </td>
649
557
  {/each}
650
558
  </tr>
651
- {#if expandedContent}
559
+ {#if expandedContent && (!expandable || isRowExpanded(row, rowIndex))}
652
560
  <tr class="expandedContent-row">
653
- <td
654
- colspan={selectable ? columns.length + 1 : columns.length}
655
- class="border-0 p-0"
656
- >
561
+ <td colspan={totalColumnCount} class="border-0 p-0">
657
562
  {@render expandedContent(row)}
658
563
  </td>
659
564
  </tr>
@@ -681,4 +586,33 @@
681
586
  </div>
682
587
  {/if}
683
588
  </div>
589
+ {/snippet}
590
+
591
+ {#if hasHeader}
592
+ <Card>
593
+ {#snippet custom()}
594
+ <!-- Header Section -->
595
+ <div class="border-default-200 mb-4 border-b pb-3">
596
+ <div class="flex items-center justify-between">
597
+ <div>
598
+ {#if title}
599
+ <h2 class="text-default-900 text-lg font-semibold">{title}</h2>
600
+ {/if}
601
+ {#if subtitle}
602
+ <p class="text-default-500 mt-1 text-xs">{subtitle}</p>
603
+ {/if}
604
+ </div>
605
+ {#if headerActions}
606
+ <div class="flex items-center">
607
+ {@render headerActions()}
608
+ </div>
609
+ {/if}
610
+ </div>
611
+ </div>
612
+
613
+ {@render tableContent()}
614
+ {/snippet}
615
+ </Card>
616
+ {:else}
617
+ {@render tableContent()}
684
618
  {/if}