@juspay/svelte-ui-components 2.80.9 → 2.81.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.
@@ -1,8 +1,15 @@
1
1
  <script lang="ts">
2
- import type { TableProperties, TableCheckboxSelectionConfig } from './properties';
2
+ import type { TableProperties, TableCheckboxSelectionConfig, TableRow } from './properties';
3
+ import { normalizeColumns } from './normalizeColumns';
4
+ import BuiltinCell from './BuiltinCell.svelte';
3
5
  import type { JSONValue } from 'type-decoder';
4
6
  import { SvelteSet } from 'svelte/reactivity';
5
7
  import Button from '../Button/Button.svelte';
8
+ import Menu from '../Menu/Menu.svelte';
9
+ import Tooltip from '../Tooltip/Tooltip.svelte';
10
+ import Pagination from '../Pagination/Pagination.svelte';
11
+ import Select from '../Select/Select.svelte';
12
+ import chevronDownSmSvg from '../assets/chevron-down-sm.svg?raw';
6
13
  import chevronUpSvg from '../assets/chevron-up.svg?raw';
7
14
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
8
15
  import sortDefaultSvg from '../assets/sort-default.svg?raw';
@@ -15,8 +22,11 @@
15
22
  tableTitle = '',
16
23
  tableHeaders = [],
17
24
  tableData = [],
25
+ columns,
26
+ rows,
18
27
  sortable = true,
19
28
  sortableColumns,
29
+ sortMode = 'client',
20
30
  stickyHeader = false,
21
31
  isTableScrollable = false,
22
32
  isContentScrollable = false,
@@ -36,9 +46,38 @@
36
46
  getCellTestId,
37
47
  checkboxSelection,
38
48
  searchConfig,
39
- onSearchChange
49
+ onSearchChange,
50
+ pagination,
51
+ toolbarSlot,
52
+ rowNumberColumn = false
40
53
  }: TableProperties = $props();
41
54
 
55
+ // ─── Keyed column model → positional projection ─────────────────────────────
56
+ // When `columns` is provided, the keyed model is normalized once and the
57
+ // positional engine below runs on the projection; when absent, the positional
58
+ // props pass through untouched, preserving existing behavior exactly.
59
+ let normalized = $derived(columns ? normalizeColumns(columns, rows ?? []) : null);
60
+ let effectiveHeaders = $derived(normalized ? normalized.tableHeaders : tableHeaders);
61
+ let effectiveData = $derived(normalized ? normalized.tableData : tableData);
62
+ let effectiveSortableColumns = $derived(
63
+ normalized ? normalized.sortableColumns : sortableColumns
64
+ );
65
+
66
+ /**
67
+ * Recovers the original keyed row from a projected positional row so
68
+ * column-scoped `cell` snippets receive the keyed shape. Sorting and
69
+ * filtering copy the outer array but keep row references, so reference
70
+ * identity survives the whole pipeline.
71
+ */
72
+ let keyedRowByProjected = $derived.by((): Map<JSONValue[], TableRow> | null => {
73
+ if (!normalized || !rows) {
74
+ return null;
75
+ }
76
+ return new Map(
77
+ normalized.tableData.map((projectedRow, rowIndex) => [projectedRow, rows[rowIndex]])
78
+ );
79
+ });
80
+
42
81
  // ─── Sort state ──────────────────────────────────────────────────────────────
43
82
  let sortColumn = $state<number | null>(null);
44
83
  let sortDirection = $state<'asc' | 'desc'>('asc');
@@ -47,8 +86,8 @@
47
86
  if (!sortable) {
48
87
  return false;
49
88
  }
50
- if (sortableColumns) {
51
- return sortableColumns.includes(colIndex);
89
+ if (effectiveSortableColumns) {
90
+ return effectiveSortableColumns.includes(colIndex);
52
91
  }
53
92
  return true;
54
93
  };
@@ -93,6 +132,7 @@
93
132
  return;
94
133
  }
95
134
  searchTerm = searchInputRef.value;
135
+ pageOverride = 1;
96
136
  if (isServerSearch) {
97
137
  onSearchChange?.(searchTerm);
98
138
  }
@@ -100,6 +140,7 @@
100
140
 
101
141
  const clearSearch = (): void => {
102
142
  searchTerm = '';
143
+ pageOverride = 1;
103
144
  if (isServerSearch) {
104
145
  onSearchChange?.('');
105
146
  }
@@ -107,14 +148,41 @@
107
148
 
108
149
  // ─── Sort + Search pipeline ──────────────────────────────────────────────────
109
150
  let sortedTableData = $derived.by(() => {
110
- if (sortColumn === null) {
111
- return [...tableData];
151
+ if (sortColumn === null || sortMode === 'server') {
152
+ return [...effectiveData];
112
153
  }
113
154
 
114
155
  const colIndex = sortColumn;
115
156
  const direction = sortDirection;
116
157
 
117
- return [...tableData].sort((rowA, rowB) => {
158
+ // Keyed-mode per-column sort-value extraction: the consumer's getSortValue
159
+ // supplies the comparable (currency/date parsing stays app-side), the
160
+ // built-in scalar comparator below does the ordering.
161
+ const getSortValue = columns?.[colIndex]?.getSortValue;
162
+ const keyedLookup = keyedRowByProjected;
163
+ if (typeof getSortValue === 'function' && keyedLookup) {
164
+ const emptyRow: TableRow = {};
165
+ const decorated = effectiveData.map((row, rowIndex) => ({
166
+ row,
167
+ sortValue: getSortValue(keyedLookup.get(row) ?? emptyRow, rowIndex)
168
+ }));
169
+ decorated.sort((entryA, entryB) => {
170
+ const valueA = entryA.sortValue;
171
+ const valueB = entryB.sortValue;
172
+ let comparison: number;
173
+ if (typeof valueA === 'number' && typeof valueB === 'number') {
174
+ comparison = valueA - valueB;
175
+ } else if (typeof valueA === 'boolean' && typeof valueB === 'boolean') {
176
+ comparison = valueA === valueB ? 0 : valueA ? -1 : 1;
177
+ } else {
178
+ comparison = String(valueA).localeCompare(String(valueB));
179
+ }
180
+ return direction === 'asc' ? comparison : -comparison;
181
+ });
182
+ return decorated.map((entry) => entry.row);
183
+ }
184
+
185
+ return [...effectiveData].sort((rowA, rowB) => {
118
186
  const valueA = rowA[colIndex];
119
187
  const valueB = rowB[colIndex];
120
188
 
@@ -170,6 +238,89 @@
170
238
  });
171
239
  });
172
240
 
241
+ // ─── Built-in pagination ─────────────────────────────────────────────────
242
+ // Client mode slices the filtered rows internally; server mode leaves the
243
+ // supplied rows untouched (they ARE the current page) and only drives the
244
+ // paginator chrome. Search input and page-size changes snap back to page 1.
245
+ let pageOverride = $state<number | null>(null);
246
+ let pageSizeOverride = $state<number | null>(null);
247
+ let paginationMode = $derived(pagination?.mode ?? 'client');
248
+ let effectivePageSize = $derived(pageSizeOverride ?? pagination?.pageSize ?? 10);
249
+ let effectivePage = $derived.by(() => {
250
+ if (!pagination) {
251
+ return 1;
252
+ }
253
+ if (paginationMode === 'server') {
254
+ return pagination.page ?? 1;
255
+ }
256
+ return pageOverride ?? pagination.page ?? 1;
257
+ });
258
+ let paginationTotalItems = $derived.by(() => {
259
+ if (!pagination) {
260
+ return 0;
261
+ }
262
+ if (paginationMode === 'server') {
263
+ return pagination.totalItems ?? 0;
264
+ }
265
+ return filteredTableData.length;
266
+ });
267
+ let paginationTotalPages = $derived(
268
+ Math.max(1, Math.ceil(paginationTotalItems / Math.max(1, effectivePageSize)))
269
+ );
270
+ let paginatedTableData = $derived.by(() => {
271
+ if (!pagination || paginationMode === 'server') {
272
+ return filteredTableData;
273
+ }
274
+ const startIndex = (effectivePage - 1) * effectivePageSize;
275
+ return filteredTableData.slice(startIndex, startIndex + effectivePageSize);
276
+ });
277
+ let paginationRangeText = $derived.by(() => {
278
+ if (!pagination) {
279
+ return '';
280
+ }
281
+ const total = paginationTotalItems;
282
+ const from = total === 0 ? 0 : (effectivePage - 1) * effectivePageSize + 1;
283
+ const to = Math.min(effectivePage * effectivePageSize, total);
284
+ if (pagination.rangeLabel) {
285
+ return pagination.rangeLabel(from, to, total);
286
+ }
287
+ return total > 0 ? `${from}-${to} of ${total}` : '';
288
+ });
289
+ let pageSizeOptions = $derived(pagination?.pageSizeOptions ?? [10, 25, 50, 100]);
290
+
291
+ const handlePageChange = (page: number): void => {
292
+ pageOverride = page;
293
+ pagination?.onPageChange?.(page);
294
+ };
295
+
296
+ const handlePageSizeChange = (nextSize: number): void => {
297
+ pageSizeOverride = nextSize;
298
+ pageOverride = 1;
299
+ pagination?.onPageSizeChange?.(nextSize);
300
+ };
301
+
302
+ /** 1-based, pagination-aware sequence number for the row-number column. */
303
+ const rowNumberFor = (rowIndex: number): number => {
304
+ if (pagination) {
305
+ return (effectivePage - 1) * effectivePageSize + rowIndex + 1;
306
+ }
307
+ return rowIndex + 1;
308
+ };
309
+
310
+ /**
311
+ * Offset that converts the render loop's page-local index back into an index
312
+ * into the consumer-supplied rows. Client-mode pagination slices internally,
313
+ * so page 2+ would otherwise hand handlers (onRowClick, column handlers,
314
+ * cell snippets, test-id callbacks) an index that mis-addresses the
315
+ * consumer's full array. Server mode supplies the current page as the whole
316
+ * array, so no offset applies.
317
+ */
318
+ let rowIndexOffset = $derived(
319
+ pagination && (pagination.mode ?? 'client') === 'client'
320
+ ? (effectivePage - 1) * effectivePageSize
321
+ : 0
322
+ );
323
+
173
324
  // ─── C2-1: Checkbox selection ─────────────────────────────────────────────
174
325
  const resolveRowId = (
175
326
  cfg: TableCheckboxSelectionConfig,
@@ -179,14 +330,23 @@
179
330
  return cfg.getRowId ? cfg.getRowId(row, rowIndex) : String(rowIndex);
180
331
  };
181
332
 
182
- let selectedIds = new SvelteSet<string>();
333
+ let internalSelectedIds = new SvelteSet<string>();
334
+
335
+ // Controlled-selection overlay: when the consumer supplies selectedIds,
336
+ // Table renders FROM that set and never mutates it — onSelectionChange
337
+ // reports the would-be next set instead. Absent (every pre-existing
338
+ // consumer), the internal reactive set behaves exactly as before.
339
+ let controlledSelectedIds = $derived(checkboxSelection?.selectedIds ?? null);
340
+ let effectiveSelectedIds = $derived<ReadonlySet<string>>(
341
+ controlledSelectedIds ?? internalSelectedIds
342
+ );
183
343
 
184
344
  const isRowDisabled = (rowId: string): boolean => {
185
345
  return checkboxSelection?.disabledRowIds?.has(rowId) ?? false;
186
346
  };
187
347
 
188
348
  const isRowSelected = (rowId: string): boolean => {
189
- return selectedIds.has(rowId);
349
+ return effectiveSelectedIds.has(rowId);
190
350
  };
191
351
 
192
352
  /**
@@ -217,20 +377,31 @@
217
377
  });
218
378
 
219
379
  /**
220
- * IDs of all selectable (non-disabled) rows in the currently filtered view.
380
+ * Row-reference stable-ID lookup so the paginated view resolves the same
381
+ * IDs as the full filtered view (slicing preserves row references).
382
+ */
383
+ let rowIdByRow = $derived(
384
+ new Map(filteredTableData.map((row, index) => [row, filteredRowIds[index]]))
385
+ );
386
+
387
+ /**
388
+ * IDs of all selectable (non-disabled) rows in the CURRENT VIEW — the page
389
+ * the user is looking at under client pagination, the whole filtered set
390
+ * otherwise (no pagination, and server mode, where the consumer's rows array
391
+ * already is the page).
221
392
  *
222
- * Iterates `filteredTableData` (the post-filter, post-sort visible rows) and
223
- * resolves each row's stable ID using `sortedTableData.indexOf(row)` for the
224
- * pre-filter positional index. When `getRowId` is absent this keeps the
225
- * default numeric ID (`String(originalIndex)`) unchanged regardless of which
226
- * rows the current search term hides, preventing ID collisions as the visible
227
- * set changes.
393
+ * The header select-all and its tri-state operate on this set, so checking
394
+ * the header selects exactly the rows the user can see. Selections made on
395
+ * other pages are left untouched by a header toggle on this page.
228
396
  */
229
397
  let selectableRowIds = $derived.by((): string[] => {
230
398
  if (!checkboxSelection || checkboxSelection.enabled === false) {
231
399
  return [];
232
400
  }
233
- return filteredRowIds.filter((rowId) => !isRowDisabled(rowId));
401
+ return paginatedTableData.flatMap((row) => {
402
+ const rowId = rowIdByRow.get(row) ?? null;
403
+ return rowId !== null && !isRowDisabled(rowId) ? [rowId] : [];
404
+ });
234
405
  });
235
406
 
236
407
  /**
@@ -247,7 +418,9 @@
247
418
  ) {
248
419
  return 'none';
249
420
  }
250
- const selectedCount = selectableRowIds.filter((rowId) => selectedIds.has(rowId)).length;
421
+ const selectedCount = selectableRowIds.filter((rowId) =>
422
+ effectiveSelectedIds.has(rowId)
423
+ ).length;
251
424
  if (selectedCount === 0) {
252
425
  return 'none';
253
426
  }
@@ -262,39 +435,64 @@
262
435
  return;
263
436
  }
264
437
 
438
+ if (controlledSelectedIds) {
439
+ const wasSelected = controlledSelectedIds.has(rowId);
440
+ const nextSelection =
441
+ checkboxSelection.selectionMode === 'single'
442
+ ? new Set(wasSelected ? [] : [rowId])
443
+ : wasSelected
444
+ ? new Set([...controlledSelectedIds].filter((selectedId) => selectedId !== rowId))
445
+ : new Set([...controlledSelectedIds, rowId]);
446
+ checkboxSelection.onSelectionChange?.(nextSelection);
447
+ return;
448
+ }
449
+
265
450
  if (checkboxSelection.selectionMode === 'single') {
266
- const wasSelected = selectedIds.has(rowId);
267
- selectedIds.clear();
451
+ const wasSelected = internalSelectedIds.has(rowId);
452
+ internalSelectedIds.clear();
268
453
  if (!wasSelected) {
269
- selectedIds.add(rowId);
454
+ internalSelectedIds.add(rowId);
270
455
  }
271
456
  } else {
272
- if (selectedIds.has(rowId)) {
273
- selectedIds.delete(rowId);
457
+ if (internalSelectedIds.has(rowId)) {
458
+ internalSelectedIds.delete(rowId);
274
459
  } else {
275
- selectedIds.add(rowId);
460
+ internalSelectedIds.add(rowId);
276
461
  }
277
462
  }
278
- checkboxSelection.onSelectionChange?.(new Set(selectedIds));
463
+ checkboxSelection.onSelectionChange?.(new Set(internalSelectedIds));
279
464
  };
280
465
 
281
466
  const toggleAllSelection = (): void => {
282
467
  if (!checkboxSelection || checkboxSelection.enabled === false) {
283
468
  return;
284
469
  }
470
+
471
+ if (controlledSelectedIds) {
472
+ const selectableSet = new Set(selectableRowIds);
473
+ const nextSelection =
474
+ headerCheckboxState === 'all'
475
+ ? new Set(
476
+ [...controlledSelectedIds].filter((selectedId) => !selectableSet.has(selectedId))
477
+ )
478
+ : new Set([...controlledSelectedIds, ...selectableRowIds]);
479
+ checkboxSelection.onSelectionChange?.(nextSelection);
480
+ return;
481
+ }
482
+
285
483
  // selectableRowIds already excludes disabled rows
286
484
  if (headerCheckboxState === 'all') {
287
485
  // deselect all selectable rows in the current view
288
486
  for (const rowId of selectableRowIds) {
289
- selectedIds.delete(rowId);
487
+ internalSelectedIds.delete(rowId);
290
488
  }
291
489
  } else {
292
490
  // select all selectable rows in the current view
293
491
  for (const rowId of selectableRowIds) {
294
- selectedIds.add(rowId);
492
+ internalSelectedIds.add(rowId);
295
493
  }
296
494
  }
297
- checkboxSelection.onSelectionChange?.(new Set(selectedIds));
495
+ checkboxSelection.onSelectionChange?.(new Set(internalSelectedIds));
298
496
  };
299
497
 
300
498
  const handleCheckboxKeydown = (event: KeyboardEvent, action: () => void): void => {
@@ -345,7 +543,13 @@
345
543
  </div>
346
544
  {/if}
347
545
 
348
- {#if tableHeaders.length !== 0 || tableData.length !== 0}
546
+ {#if typeof toolbarSlot === 'function' && isCheckboxMode && effectiveSelectedIds.size > 0}
547
+ <div class="table-toolbar">
548
+ {@render toolbarSlot({ selectedIds: new Set(effectiveSelectedIds) })}
549
+ </div>
550
+ {/if}
551
+
552
+ {#if effectiveHeaders.length !== 0 || effectiveData.length !== 0}
349
553
  <div
350
554
  class="table-container {isTableScrollable ? 'scrollable-table' : ''} {classes ?? ''}"
351
555
  data-pw={testId}
@@ -369,6 +573,9 @@
369
573
  ? 'mixed'
370
574
  : headerCheckboxState === 'all'}
371
575
  aria-label="Select all rows"
576
+ {...checkboxSelection?.getRowAttributes
577
+ ? checkboxSelection.getRowAttributes('__header__', -1)
578
+ : {}}
372
579
  aria-controls={selectableRowIds.map((rowId) => `row-checkbox-${rowId}`).join(' ')}
373
580
  onclick={toggleAllSelection}
374
581
  onkeydown={(keyboardEvent) =>
@@ -388,10 +595,71 @@
388
595
  <th class="table-header table-checkbox-col" class:table-header-sticky={isStickyHeader}>
389
596
  </th>
390
597
  {/if}
391
- {#each tableHeaders as header, colIndex (colIndex)}
392
- <th class="table-header" class:table-header-sticky={isStickyHeader}>
393
- <span class="table-header-content">
394
- {header}
598
+ {#if rowNumberColumn}
599
+ <th class="table-header table-row-number-col" class:table-header-sticky={isStickyHeader}
600
+ >#</th
601
+ >
602
+ {/if}
603
+ {#each effectiveHeaders as header, colIndex (colIndex)}
604
+ {@const headerColumn = columns?.[colIndex]}
605
+ <th
606
+ class="table-header"
607
+ class:table-header-sticky={isStickyHeader}
608
+ data-pw={headerColumn?.testId ?? null}
609
+ style:text-align={headerColumn?.align ?? null}
610
+ style:max-width={headerColumn?.maxWidth ?? null}
611
+ >
612
+ <span
613
+ class="table-header-content"
614
+ style:justify-content={headerColumn?.align === 'right'
615
+ ? 'flex-end'
616
+ : headerColumn?.align === 'center'
617
+ ? 'center'
618
+ : null}
619
+ >
620
+ {#if headerColumn?.tooltip}
621
+ <Tooltip text={headerColumn.tooltip}>
622
+ <span class="table-header-label">{header}</span>
623
+ </Tooltip>
624
+ {:else}
625
+ {header}
626
+ {/if}
627
+ {#if headerColumn?.filter}
628
+ {@const filter = headerColumn.filter}
629
+ <span class="table-header-filter">
630
+ <Menu
631
+ items={filter.options.map((option) => ({
632
+ value: option.value,
633
+ label: option.label
634
+ }))}
635
+ selectedValue={filter.selectedValue ?? null}
636
+ role="listbox"
637
+ testId={headerColumn.testId && `${headerColumn.testId}-filter`}
638
+ onselect={(menuItem) =>
639
+ filter.onFilterChange?.(
640
+ menuItem.value === filter.selectedValue ? null : menuItem.value
641
+ )}
642
+ >
643
+ {#snippet trigger()}
644
+ <span
645
+ class="table-header-filter-trigger"
646
+ class:table-header-filter-active={typeof filter.selectedValue ===
647
+ 'string'}
648
+ >
649
+ <Button
650
+ ariaLabel="Filter by {header}"
651
+ testId={headerColumn.testId && `${headerColumn.testId}-filter-trigger`}
652
+ >
653
+ {#snippet icon()}
654
+ <!-- eslint-disable svelte/no-at-html-tags -->
655
+ <span class="table-header-filter-icon">{@html chevronDownSmSvg}</span>
656
+ {/snippet}
657
+ </Button>
658
+ </span>
659
+ {/snippet}
660
+ </Menu>
661
+ </span>
662
+ {/if}
395
663
  {#if isColumnSortable(colIndex)}
396
664
  <div class="sort-button">
397
665
  <Button onclick={() => handleSort(colIndex)} ariaLabel="Sort by {header}">
@@ -434,14 +702,17 @@
434
702
  <tr>
435
703
  <td
436
704
  class="table-empty"
437
- colspan={isCheckboxMode ? tableHeaders.length + 1 : tableHeaders.length}
705
+ colspan={effectiveHeaders.length +
706
+ (isCheckboxMode ? 1 : 0) +
707
+ (rowNumberColumn ? 1 : 0)}
438
708
  >
439
709
  {@render empty()}
440
710
  </td>
441
711
  </tr>
442
712
  {:else}
443
- {#each filteredTableData as row, rowIndex (filteredRowIds[rowIndex])}
444
- {@const rowId = filteredRowIds[rowIndex]}
713
+ {#each paginatedTableData as row, pageRowIndex (rowIdByRow.get(row) ?? pageRowIndex)}
714
+ {@const rowIndex = pageRowIndex + rowIndexOffset}
715
+ {@const rowId = rowIdByRow.get(row) ?? String(rowIndex)}
445
716
  {@const rowDisabled = isCheckboxMode && isRowDisabled(rowId)}
446
717
  {@const rowSelected = isCheckboxMode && isRowSelected(rowId)}
447
718
  <tr
@@ -466,6 +737,10 @@
466
737
  tabindex={rowDisabled ? -1 : 0}
467
738
  aria-checked={rowSelected}
468
739
  aria-disabled={rowDisabled}
740
+ aria-label={`Select row ${rowId || 'non-selectable'}`}
741
+ {...checkboxSelection?.getRowAttributes
742
+ ? checkboxSelection.getRowAttributes(rowId, rowIndex)
743
+ : {}}
469
744
  onclick={(mouseEvent) => {
470
745
  mouseEvent.stopPropagation();
471
746
  toggleRowSelection(rowId);
@@ -482,15 +757,34 @@
482
757
  </span>
483
758
  </td>
484
759
  {/if}
760
+ {#if rowNumberColumn}
761
+ <td class="table-content table-row-number-col">{rowNumberFor(pageRowIndex)}</td>
762
+ {/if}
485
763
  {#each row as cellValue, colIndex (colIndex)}
764
+ {@const keyedColumn = columns?.[colIndex]}
765
+ {@const keyedRow = keyedRowByProjected?.get(row)}
766
+ {@const isScalarCell =
767
+ typeof cellValue === 'string' ||
768
+ typeof cellValue === 'number' ||
769
+ typeof cellValue === 'boolean'}
486
770
  <td
487
771
  class="table-content"
488
772
  data-pw={typeof getCellTestId === 'function'
489
773
  ? getCellTestId(row, cellValue, rowIndex)
490
774
  : null}
775
+ style:text-align={keyedColumn?.align ?? null}
776
+ style:max-width={keyedColumn?.maxWidth ?? null}
777
+ title={keyedColumn?.maxWidth && isScalarCell ? String(cellValue) : null}
491
778
  >
492
- <div class={isContentScrollable ? 'scrollable-content' : ''}>
493
- {#if typeof cell === 'function'}
779
+ <div
780
+ class={isContentScrollable ? 'scrollable-content' : ''}
781
+ class:table-cell-clamp={keyedColumn?.maxWidth && isScalarCell}
782
+ >
783
+ {#if keyedColumn && typeof keyedColumn.cell === 'function' && keyedRow}
784
+ {@render keyedColumn.cell(keyedRow, rowIndex)}
785
+ {:else if keyedColumn?.type && keyedColumn.type !== 'text' && keyedColumn.type !== 'custom'}
786
+ <BuiltinCell column={keyedColumn} value={cellValue} {rowIndex} />
787
+ {:else if typeof cell === 'function'}
494
788
  {@render cell(cellValue, rowIndex, colIndex)}
495
789
  {:else}
496
790
  {cellValue}
@@ -507,6 +801,42 @@
507
801
  <div class="table-footer">
508
802
  {@render paginatorSlot()}
509
803
  </div>
804
+ {:else if pagination && (paginationTotalPages > 1 || pagination.hasMore)}
805
+ <!-- DataGrid parity: pagination chrome only renders when the data spans
806
+ more than one page (or the server reports more chunks); a
807
+ single-page table shows no footer. -->
808
+ <div class="table-footer table-paginator" data-pw={pagination.testId ?? null}>
809
+ <span class="table-paginator-range">{paginationRangeText}</span>
810
+ <span class="table-paginator-controls">
811
+ {#if pageSizeOptions.length > 0}
812
+ <span class="table-paginator-size">
813
+ <Select
814
+ items={pageSizeOptions.map((sizeOption) => ({
815
+ id: String(sizeOption),
816
+ label: String(sizeOption)
817
+ }))}
818
+ value={[String(effectivePageSize)]}
819
+ disabled={pagination.isLoading ?? false}
820
+ testId={pagination.testId && `${pagination.testId}-page-size`}
821
+ onchange={(selectedSizes) => {
822
+ if (selectedSizes.length > 0) {
823
+ handlePageSizeChange(Number(selectedSizes[0]));
824
+ }
825
+ }}
826
+ />
827
+ </span>
828
+ {/if}
829
+ <Pagination
830
+ totalPages={paginationTotalPages}
831
+ currentPage={effectivePage}
832
+ hasMore={pagination.hasMore ?? false}
833
+ disabled={pagination.isLoading ?? false}
834
+ testId={pagination.testId && `${pagination.testId}-pages`}
835
+ onchange={handlePageChange}
836
+ onLoadMore={pagination.onLoadMore}
837
+ />
838
+ </span>
839
+ </div>
510
840
  {/if}
511
841
  </div>
512
842
  {/if}
@@ -617,7 +947,13 @@
617
947
  padding: var(--table-padding, 12px 16px);
618
948
  text-align: var(--table-text-align, left);
619
949
  width: var(--table-column-width);
620
- word-break: break-all;
950
+ /* Wrap at word boundaries; only a single word wider than the column may
951
+ split as a last resort. break-word (not anywhere) keeps each column's
952
+ min-content width at its longest word, so ordinary labels never split
953
+ mid-word — the old break-all default did. Opt back in per consumer via
954
+ --table-word-break / --table-overflow-wrap. */
955
+ word-break: var(--table-word-break, normal);
956
+ overflow-wrap: var(--table-overflow-wrap, break-word);
621
957
  }
622
958
 
623
959
  .scrollable-content {
@@ -641,6 +977,53 @@
641
977
  gap: 4px;
642
978
  }
643
979
 
980
+ .table-header-label {
981
+ text-decoration: var(--table-header-tooltip-underline, underline dotted);
982
+ text-underline-offset: 2px;
983
+ cursor: help;
984
+ }
985
+
986
+ .table-header-filter {
987
+ display: inline-flex;
988
+ align-items: center;
989
+ }
990
+
991
+ .table-header-filter-trigger {
992
+ --button-color: transparent;
993
+ --button-border: none;
994
+ --button-padding: 2px;
995
+ --button-margin: 0;
996
+ --button-width: fit-content;
997
+ --button-height: fit-content;
998
+ --button-text-color: var(--table-sort-button-color, inherit);
999
+ --button-border-radius: 4px;
1000
+ --button-hover-color: var(--table-sort-button-hover-background, rgba(0, 0, 0, 0.05));
1001
+ display: inline-flex;
1002
+ align-items: center;
1003
+ opacity: var(--table-sort-idle-opacity, 0.5);
1004
+ }
1005
+
1006
+ .table-header-filter-trigger:hover {
1007
+ opacity: var(--table-sort-idle-hover-opacity, 0.85);
1008
+ }
1009
+
1010
+ .table-header-filter-active {
1011
+ opacity: 1;
1012
+ color: var(--table-filter-active-color, #2563eb);
1013
+ }
1014
+
1015
+ .table-header-filter-icon :global(svg) {
1016
+ width: var(--table-sort-icon-size, 14px);
1017
+ height: var(--table-sort-icon-size, 14px);
1018
+ display: block;
1019
+ }
1020
+
1021
+ .table-cell-clamp {
1022
+ overflow: hidden;
1023
+ text-overflow: ellipsis;
1024
+ white-space: nowrap;
1025
+ }
1026
+
644
1027
  .table-header-sticky {
645
1028
  position: sticky;
646
1029
  top: var(--table-header-sticky-top, 0);
@@ -810,6 +1193,50 @@
810
1193
  background-color: var(--table-footer-background, transparent);
811
1194
  }
812
1195
 
1196
+ .table-paginator {
1197
+ display: flex;
1198
+ align-items: center;
1199
+ justify-content: space-between;
1200
+ gap: var(--table-paginator-gap, 12px);
1201
+ flex-wrap: wrap;
1202
+ }
1203
+
1204
+ .table-paginator-range {
1205
+ font-size: var(--table-paginator-range-font-size, 13px);
1206
+ color: var(--table-paginator-range-color, #6b7280);
1207
+ font-variant-numeric: tabular-nums;
1208
+ }
1209
+
1210
+ .table-paginator-controls {
1211
+ display: flex;
1212
+ align-items: center;
1213
+ gap: var(--table-paginator-gap, 12px);
1214
+ }
1215
+
1216
+ .table-paginator-size {
1217
+ display: inline-flex;
1218
+ min-width: var(--table-paginator-size-width, 72px);
1219
+ }
1220
+
1221
+ /* ── Toolbar (bulk actions) ─────────────────────────────────────────────── */
1222
+ .table-toolbar {
1223
+ display: flex;
1224
+ align-items: center;
1225
+ gap: var(--table-toolbar-gap, 12px);
1226
+ padding: var(--table-toolbar-padding, 8px 12px);
1227
+ border: var(--table-toolbar-border, 1px solid #e5e7eb);
1228
+ border-radius: var(--table-toolbar-border-radius, var(--radius, 4px));
1229
+ background-color: var(--table-toolbar-background, #f9fafb);
1230
+ margin-bottom: var(--table-toolbar-margin-bottom, 8px);
1231
+ }
1232
+
1233
+ /* ── Row-number column ──────────────────────────────────────────────────── */
1234
+ .table-row-number-col {
1235
+ width: var(--table-row-number-col-width, 48px);
1236
+ color: var(--table-row-number-color, #6b7280);
1237
+ font-variant-numeric: tabular-nums;
1238
+ }
1239
+
813
1240
  /* ── Accessibility ──────────────────────────────────────────────────────── */
814
1241
  .sr-only {
815
1242
  position: absolute;