compote-ui 0.36.0 → 0.36.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.
- package/dist/components/data-table/data-table.svelte +418 -418
- package/package.json +1 -1
- package/dist/components/data-table-old/core/cells.d.ts +0 -78
- package/dist/components/data-table-old/core/cells.js +0 -66
- package/dist/components/data-table-old/core/create-table.svelte.d.ts +0 -24
- package/dist/components/data-table-old/core/create-table.svelte.js +0 -74
- package/dist/components/data-table-old/core/index.d.ts +0 -3
- package/dist/components/data-table-old/core/index.js +0 -2
- package/dist/components/data-table-old/data-table-column-visibility.svelte +0 -79
- package/dist/components/data-table-old/data-table-column-visibility.svelte.d.ts +0 -29
- package/dist/components/data-table-old/data-table-filters.svelte +0 -285
- package/dist/components/data-table-old/data-table-filters.svelte.d.ts +0 -29
- package/dist/components/data-table-old/data-table-title.svelte +0 -16
- package/dist/components/data-table-old/data-table-title.svelte.d.ts +0 -10
- package/dist/components/data-table-old/data-table-toolbar.svelte +0 -16
- package/dist/components/data-table-old/data-table-toolbar.svelte.d.ts +0 -10
- package/dist/components/data-table-old/data-table.svelte +0 -342
- package/dist/components/data-table-old/data-table.svelte.d.ts +0 -32
- package/dist/components/data-table-old/index.d.ts +0 -7
- package/dist/components/data-table-old/index.js +0 -7
|
@@ -1,418 +1,418 @@
|
|
|
1
|
-
<script lang="ts" generics="T extends RowData">
|
|
2
|
-
import { FlexRender } from '@tanstack/svelte-table';
|
|
3
|
-
import type { CellData, ColumnPinningPosition, Header, RowData } from '@tanstack/svelte-table';
|
|
4
|
-
import type { HTMLAttributes } from 'svelte/elements';
|
|
5
|
-
import { cn, type ClassValue } from 'tailwind-variants';
|
|
6
|
-
import { PhArrowSquareOut, PhCaretDown, PhCaretUp, PhCheck, PhX } from '../../icons';
|
|
7
|
-
import Checkbox from '../checkbox/checkbox.svelte';
|
|
8
|
-
import { getColumnId, type DataTableFeatures, type DataTableInstance } from './create-table';
|
|
9
|
-
import type { DataTableColumn, DataTableGroupColumn, DataTableLeafColumn } from './types';
|
|
10
|
-
|
|
11
|
-
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
|
|
12
|
-
table: DataTableInstance<T>;
|
|
13
|
-
columns: DataTableColumn<T>[];
|
|
14
|
-
caption?: string;
|
|
15
|
-
emptyMessage?: string;
|
|
16
|
-
class?: ClassValue;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
let {
|
|
20
|
-
table,
|
|
21
|
-
columns,
|
|
22
|
-
caption,
|
|
23
|
-
emptyMessage = 'No rows found',
|
|
24
|
-
class: className,
|
|
25
|
-
...rest
|
|
26
|
-
}: Props = $props();
|
|
27
|
-
|
|
28
|
-
function alignClass(align: DataTableColumn<T>['align']) {
|
|
29
|
-
return align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function justifyClass(align: DataTableColumn<T>['align']) {
|
|
33
|
-
return align === 'right'
|
|
34
|
-
? 'justify-end'
|
|
35
|
-
: align === 'center'
|
|
36
|
-
? 'justify-center'
|
|
37
|
-
: 'justify-start';
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function sortButtonDirectionClass(align: DataTableColumn<T>['align']) {
|
|
41
|
-
return align === 'right' ? 'flex-row-reverse' : 'flex-row';
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function columnSizeStyle(size: number) {
|
|
45
|
-
return `width: ${size}px`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function selectionColumnSizeStyle() {
|
|
49
|
-
return 'width: 40px';
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function tableSizeStyle() {
|
|
53
|
-
return `width: max(100%, ${table.getTotalSize() + (isRowSelectionEnabled ? 40 : 0)}px)`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function resizeHandleStyle(header: Header<DataTableFeatures, T, CellData>) {
|
|
57
|
-
if (table.options.columnResizeMode !== 'onEnd') return undefined;
|
|
58
|
-
const deltaOffset = table.store.state.columnResizing.deltaOffset;
|
|
59
|
-
if (!header.column.getIsResizing() || deltaOffset === null) return undefined;
|
|
60
|
-
return `transform: translateX(${deltaOffset}px)`;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function resizeHandleClass(headerIndex: number, headerCount: number) {
|
|
64
|
-
return cn(
|
|
65
|
-
'absolute top-0 z-10 flex h-full w-2 cursor-col-resize touch-none items-center justify-center select-none before:h-4 before:w-px before:bg-border before:content-[""]',
|
|
66
|
-
headerIndex === headerCount - 1 ? 'right-0' : '-right-1'
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function getHeaderSortDirection(
|
|
71
|
-
header: Header<DataTableFeatures, T, CellData>,
|
|
72
|
-
sortingState: unknown
|
|
73
|
-
) {
|
|
74
|
-
void sortingState;
|
|
75
|
-
return header.column.getIsSorted();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function getHeaderSortLabel(sortDirection: false | 'asc' | 'desc') {
|
|
79
|
-
if (sortDirection === 'asc') return 'Sorted ascending';
|
|
80
|
-
if (sortDirection === 'desc') return 'Sorted descending';
|
|
81
|
-
return 'Not sorted';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function getHeaderAriaSort(sortDirection: false | 'asc' | 'desc') {
|
|
85
|
-
if (sortDirection === 'asc') return 'ascending';
|
|
86
|
-
if (sortDirection === 'desc') return 'descending';
|
|
87
|
-
return 'none';
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function getAllRowsSelectionState(rowSelection: unknown) {
|
|
91
|
-
void rowSelection;
|
|
92
|
-
const allRowsSelected = table.getIsAllRowsSelected();
|
|
93
|
-
const someRowsSelected = table.getIsSomeRowsSelected();
|
|
94
|
-
return allRowsSelected ? true : someRowsSelected ? 'indeterminate' : false;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function getRowSelectionState(rowSelection: unknown, rowId: string) {
|
|
98
|
-
void rowSelection;
|
|
99
|
-
return table.getRow(rowId).getIsSelected();
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function getSelectedRowCount(rowSelection: unknown) {
|
|
103
|
-
void rowSelection;
|
|
104
|
-
return table.getSelectedRowModel().rows.length;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function getBooleanCellValue(value: unknown) {
|
|
108
|
-
if (value === true) return true;
|
|
109
|
-
if (value === false) return false;
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// type PinPosition = 'center' | false | 'left' | 'right';
|
|
114
|
-
function getPinningStyle(
|
|
115
|
-
column: {
|
|
116
|
-
getIsPinned(): ColumnPinningPosition;
|
|
117
|
-
getStart(position?: ColumnPinningPosition): number;
|
|
118
|
-
getAfter(position?: ColumnPinningPosition): number;
|
|
119
|
-
getIsLastColumn(position?: ColumnPinningPosition): boolean;
|
|
120
|
-
getIsFirstColumn(position?: ColumnPinningPosition): boolean;
|
|
121
|
-
},
|
|
122
|
-
isHeader = false
|
|
123
|
-
): string | undefined {
|
|
124
|
-
const isPinned = column.getIsPinned();
|
|
125
|
-
if (!isPinned) return undefined;
|
|
126
|
-
|
|
127
|
-
const zIndex = isHeader ? 15 : 1;
|
|
128
|
-
const selectionOffset = isRowSelectionEnabled ? 40 : 0;
|
|
129
|
-
|
|
130
|
-
if (isPinned === 'left') {
|
|
131
|
-
const left = column.getStart('left') + selectionOffset;
|
|
132
|
-
const shadow =
|
|
133
|
-
!isHeader && column.getIsLastColumn('left')
|
|
134
|
-
? 'box-shadow: -4px 0 4px -4px var(--compote-border) inset'
|
|
135
|
-
: undefined;
|
|
136
|
-
return ['position: sticky', `z-index: ${zIndex}`, `left: ${left}px`, shadow]
|
|
137
|
-
.filter(Boolean)
|
|
138
|
-
.join('; ');
|
|
139
|
-
} else {
|
|
140
|
-
const right = column.getAfter('right');
|
|
141
|
-
const shadow =
|
|
142
|
-
!isHeader && column.getIsFirstColumn('right')
|
|
143
|
-
? 'box-shadow: 4px 0 4px -4px var(--compote-border) inset'
|
|
144
|
-
: undefined;
|
|
145
|
-
return ['position: sticky', `z-index: ${zIndex}`, `right: ${right}px`, shadow]
|
|
146
|
-
.filter(Boolean)
|
|
147
|
-
.join('; ');
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function getUrlCellValue(value: unknown) {
|
|
152
|
-
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
|
153
|
-
return value;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function openUrlCell(value: string) {
|
|
157
|
-
window.open(value, '_blank', 'noopener,noreferrer');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function isGroupColumn(column: DataTableColumn<T>): column is DataTableGroupColumn<T> {
|
|
161
|
-
return Array.isArray(column.columns);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isLeafColumn(column: DataTableColumn<T>): column is DataTableLeafColumn<T> {
|
|
165
|
-
return !isGroupColumn(column);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function findColumnById(
|
|
169
|
-
columnId: string,
|
|
170
|
-
candidates: DataTableColumn<T>[]
|
|
171
|
-
): DataTableColumn<T> | undefined {
|
|
172
|
-
for (const column of candidates) {
|
|
173
|
-
if (isGroupColumn(column)) {
|
|
174
|
-
if ((column.id ?? column.header) === columnId) return column;
|
|
175
|
-
|
|
176
|
-
const found = findColumnById(columnId, column.columns);
|
|
177
|
-
if (found) return found;
|
|
178
|
-
|
|
179
|
-
continue;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (isLeafColumn(column) && getColumnId(column) === columnId) return column;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const tableStateKey = $derived(JSON.stringify(table.store.state));
|
|
187
|
-
function trackTableState() {
|
|
188
|
-
return tableStateKey;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const rowModel = $derived.by(() => {
|
|
192
|
-
trackTableState();
|
|
193
|
-
return table.getRowModel();
|
|
194
|
-
});
|
|
195
|
-
const headerGroups = $derived.by(() => {
|
|
196
|
-
trackTableState();
|
|
197
|
-
return table.getHeaderGroups();
|
|
198
|
-
});
|
|
199
|
-
const visibleLeafColumns = $derived.by(() => {
|
|
200
|
-
trackTableState();
|
|
201
|
-
return table.getVisibleLeafColumns();
|
|
202
|
-
});
|
|
203
|
-
const visibleColumnCount = $derived(visibleLeafColumns.length);
|
|
204
|
-
const isRowSelectionEnabled = $derived(Boolean(table.options.enableRowSelection));
|
|
205
|
-
const isMultiRowSelectionEnabled = $derived(table.options.enableMultiRowSelection !== false);
|
|
206
|
-
const tableColumnCount = $derived(visibleColumnCount + (isRowSelectionEnabled ? 1 : 0));
|
|
207
|
-
const renderedColumnCount = $derived(tableColumnCount + 1);
|
|
208
|
-
const headerGroupCount = $derived(headerGroups.length);
|
|
209
|
-
const allRowsSelectionState = $derived(getAllRowsSelectionState(table.store.state.rowSelection));
|
|
210
|
-
const selectedRowCount = $derived(getSelectedRowCount(table.store.state.rowSelection));
|
|
211
|
-
const isColumnResizing = $derived(table.store.state.columnResizing.isResizingColumn !== false);
|
|
212
|
-
</script>
|
|
213
|
-
|
|
214
|
-
<div
|
|
215
|
-
class={cn(
|
|
216
|
-
'flex max-h-full min-h-0 flex-col overflow-hidden rounded-lg border border-surface-3 bg-surface-1',
|
|
217
|
-
className
|
|
218
|
-
)}
|
|
219
|
-
{...rest}
|
|
220
|
-
>
|
|
221
|
-
{#if isColumnResizing}
|
|
222
|
-
<div aria-hidden="true" class="fixed inset-0 z-50 cursor-col-resize select-none"></div>
|
|
223
|
-
{/if}
|
|
224
|
-
|
|
225
|
-
<div class="min-h-0 flex-1 overflow-auto">
|
|
226
|
-
<table class="table-fixed border-
|
|
227
|
-
<colgroup>
|
|
228
|
-
{#if isRowSelectionEnabled}
|
|
229
|
-
<col style={selectionColumnSizeStyle()} />
|
|
230
|
-
{/if}
|
|
231
|
-
{#each visibleLeafColumns as column (column.id)}
|
|
232
|
-
<col style={columnSizeStyle(column.getSize())} />
|
|
233
|
-
{/each}
|
|
234
|
-
<col />
|
|
235
|
-
</colgroup>
|
|
236
|
-
{#if caption}
|
|
237
|
-
<caption class="sr-only">{caption}</caption>
|
|
238
|
-
{/if}
|
|
239
|
-
<thead class="sticky top-0 z-20 bg-surface-2 text-left text-ink-dim">
|
|
240
|
-
{#each headerGroups as headerGroup, headerGroupIndex (headerGroup.id)}
|
|
241
|
-
{@const visibleHeaders = headerGroup.headers.filter((header) => header.colSpan > 0)}
|
|
242
|
-
<tr>
|
|
243
|
-
{#if isRowSelectionEnabled && headerGroupIndex === 0}
|
|
244
|
-
<th
|
|
245
|
-
class="border-b border-surface-3 bg-surface-2 px-3 py-
|
|
246
|
-
style="position: sticky; left: 0; z-index: 15"
|
|
247
|
-
rowspan={headerGroupCount}
|
|
248
|
-
>
|
|
249
|
-
{#if isMultiRowSelectionEnabled}
|
|
250
|
-
<Checkbox
|
|
251
|
-
size="sm"
|
|
252
|
-
aria-label="Select all rows"
|
|
253
|
-
class="mx-auto size-4"
|
|
254
|
-
checked={allRowsSelectionState}
|
|
255
|
-
onCheckedChange={({ checked }) => table.toggleAllRowsSelected(checked === true)}
|
|
256
|
-
/>
|
|
257
|
-
{/if}
|
|
258
|
-
</th>
|
|
259
|
-
{/if}
|
|
260
|
-
{#each visibleHeaders as header, headerIndex (header.id)}
|
|
261
|
-
{@const columnDef = findColumnById(header.column.id, columns)}
|
|
262
|
-
{@const sortDirection = getHeaderSortDirection(header, table.store.state.sorting)}
|
|
263
|
-
<th
|
|
264
|
-
class={cn(
|
|
265
|
-
'relative border-b border-surface-3 bg-surface-2 px-3 py-
|
|
266
|
-
alignClass(columnDef?.align)
|
|
267
|
-
)}
|
|
268
|
-
colspan={header.colSpan}
|
|
269
|
-
aria-sort={header.column.getCanSort()
|
|
270
|
-
? getHeaderAriaSort(sortDirection)
|
|
271
|
-
: undefined}
|
|
272
|
-
style={getPinningStyle(header.column, true)}
|
|
273
|
-
>
|
|
274
|
-
{#if !header.isPlaceholder}
|
|
275
|
-
{#if header.column.getCanSort()}
|
|
276
|
-
<button
|
|
277
|
-
type="button"
|
|
278
|
-
class={cn(
|
|
279
|
-
'inline-flex max-w-full items-center gap-1 rounded-sm outline-none hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-offset-2 data-focus-visible:outline-ring',
|
|
280
|
-
justifyClass(columnDef?.align),
|
|
281
|
-
sortButtonDirectionClass(columnDef?.align)
|
|
282
|
-
)}
|
|
283
|
-
aria-label={`${getHeaderSortLabel(sortDirection)}. Toggle sorting.`}
|
|
284
|
-
onclick={header.column.getToggleSortingHandler()}
|
|
285
|
-
>
|
|
286
|
-
<span class="min-w-0 truncate">
|
|
287
|
-
<FlexRender {header} />
|
|
288
|
-
</span>
|
|
289
|
-
<span
|
|
290
|
-
class="inline-flex size-3.5 shrink-0 items-center justify-center text-ink-dim"
|
|
291
|
-
>
|
|
292
|
-
{#if sortDirection === 'asc'}
|
|
293
|
-
<PhCaretUp class="size-3.5" />
|
|
294
|
-
{:else if sortDirection === 'desc'}
|
|
295
|
-
<PhCaretDown class="size-3.5" />
|
|
296
|
-
{/if}
|
|
297
|
-
</span>
|
|
298
|
-
</button>
|
|
299
|
-
{:else}
|
|
300
|
-
<FlexRender {header} />
|
|
301
|
-
{/if}
|
|
302
|
-
{/if}
|
|
303
|
-
{#if header.column.getCanResize()}
|
|
304
|
-
<div
|
|
305
|
-
aria-hidden="true"
|
|
306
|
-
class={resizeHandleClass(headerIndex, visibleHeaders.length)}
|
|
307
|
-
style={resizeHandleStyle(header)}
|
|
308
|
-
ondblclick={() => header.column.resetSize()}
|
|
309
|
-
onmousedown={header.getResizeHandler()}
|
|
310
|
-
ontouchstart={header.getResizeHandler()}
|
|
311
|
-
></div>
|
|
312
|
-
{/if}
|
|
313
|
-
</th>
|
|
314
|
-
{/each}
|
|
315
|
-
<th aria-hidden="true" class="border-b border-surface-3 bg-surface-2 p-0"></th>
|
|
316
|
-
</tr>
|
|
317
|
-
{/each}
|
|
318
|
-
</thead>
|
|
319
|
-
<tbody>
|
|
320
|
-
{#each rowModel.rows as row (row.id)}
|
|
321
|
-
{@const rowSelected = getRowSelectionState(table.store.state.rowSelection, row.id)}
|
|
322
|
-
<tr
|
|
323
|
-
class={cn(
|
|
324
|
-
'group/row border-b border-surface-3 last:border-b-0',
|
|
325
|
-
'[--row-bg:var(--compote-surface-1)]',
|
|
326
|
-
'hover:bg-well/60 hover:[--row-bg:color-mix(in_srgb,var(--compote-well)_60%,var(--compote-surface-1))]',
|
|
327
|
-
rowSelected &&
|
|
328
|
-
'bg-well/60 [--row-bg:color-mix(in_srgb,var(--compote-well)_60%,var(--compote-surface-1))]'
|
|
329
|
-
)}
|
|
330
|
-
>
|
|
331
|
-
{#if isRowSelectionEnabled}
|
|
332
|
-
<td
|
|
333
|
-
class="bg-(--row-bg) px-3 py-2 text-center align-middle"
|
|
334
|
-
style="position: sticky; left: 0; z-index: 1"
|
|
335
|
-
>
|
|
336
|
-
<Checkbox
|
|
337
|
-
size="sm"
|
|
338
|
-
aria-label="Select row"
|
|
339
|
-
class="mx-auto size-4"
|
|
340
|
-
checked={rowSelected}
|
|
341
|
-
disabled={!row.getCanSelect()}
|
|
342
|
-
onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}
|
|
343
|
-
/>
|
|
344
|
-
</td>
|
|
345
|
-
{/if}
|
|
346
|
-
{#each row.getVisibleCells() as cell (cell.id)}
|
|
347
|
-
{@const columnDef = findColumnById(cell.column.id, columns)}
|
|
348
|
-
<td
|
|
349
|
-
class={cn(
|
|
350
|
-
'truncate px-3 py-2',
|
|
351
|
-
alignClass(columnDef?.align),
|
|
352
|
-
cell.column.getIsPinned() && 'bg-(--row-bg)'
|
|
353
|
-
)}
|
|
354
|
-
style={getPinningStyle(cell.column)}
|
|
355
|
-
>
|
|
356
|
-
{#if columnDef?.type === 'boolean'}
|
|
357
|
-
{@const value = getBooleanCellValue(cell.getValue())}
|
|
358
|
-
{#if value === true}
|
|
359
|
-
<span
|
|
360
|
-
class="inline-flex size-5 items-center justify-center text-success"
|
|
361
|
-
role="img"
|
|
362
|
-
aria-label="Yes"
|
|
363
|
-
>
|
|
364
|
-
<PhCheck class="size-4" />
|
|
365
|
-
</span>
|
|
366
|
-
{:else if value === false}
|
|
367
|
-
<span
|
|
368
|
-
class="inline-flex size-5 items-center justify-center text-danger"
|
|
369
|
-
role="img"
|
|
370
|
-
aria-label="No"
|
|
371
|
-
>
|
|
372
|
-
<PhX class="size-4" />
|
|
373
|
-
</span>
|
|
374
|
-
{:else}
|
|
375
|
-
-
|
|
376
|
-
{/if}
|
|
377
|
-
{:else if columnDef?.type === 'url'}
|
|
378
|
-
{@const value = getUrlCellValue(cell.getValue())}
|
|
379
|
-
{#if value}
|
|
380
|
-
<button
|
|
381
|
-
type="button"
|
|
382
|
-
class={cn(
|
|
383
|
-
'inline-flex max-w-full items-center gap-1.5 rounded-sm font-medium text-ink underline decoration-border decoration-dotted underline-offset-4 outline-none hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
384
|
-
justifyClass(columnDef.align)
|
|
385
|
-
)}
|
|
386
|
-
onclick={() => openUrlCell(value)}
|
|
387
|
-
>
|
|
388
|
-
<PhArrowSquareOut class="size-3.5 shrink-0" />
|
|
389
|
-
</button>
|
|
390
|
-
{:else}
|
|
391
|
-
-
|
|
392
|
-
{/if}
|
|
393
|
-
{:else}
|
|
394
|
-
<FlexRender {cell} />
|
|
395
|
-
{/if}
|
|
396
|
-
</td>
|
|
397
|
-
{/each}
|
|
398
|
-
<td aria-hidden="true" class="p-0"></td>
|
|
399
|
-
</tr>
|
|
400
|
-
{:else}
|
|
401
|
-
<tr>
|
|
402
|
-
<td class="px-3 py-10 text-center text-sm text-ink-dim" colspan={renderedColumnCount}>
|
|
403
|
-
{emptyMessage}
|
|
404
|
-
</td>
|
|
405
|
-
</tr>
|
|
406
|
-
{/each}
|
|
407
|
-
</tbody>
|
|
408
|
-
</table>
|
|
409
|
-
</div>
|
|
410
|
-
|
|
411
|
-
<div class="shrink-0 border-t border-surface-3 bg-surface-2 px-3 py-2 text-sm text-ink-dim">
|
|
412
|
-
{#if isRowSelectionEnabled}
|
|
413
|
-
{selectedRowCount} of {rowModel.rows.length} rows selected
|
|
414
|
-
{:else}
|
|
415
|
-
{rowModel.rows.length} rows
|
|
416
|
-
{/if}
|
|
417
|
-
</div>
|
|
418
|
-
</div>
|
|
1
|
+
<script lang="ts" generics="T extends RowData">
|
|
2
|
+
import { FlexRender } from '@tanstack/svelte-table';
|
|
3
|
+
import type { CellData, ColumnPinningPosition, Header, RowData } from '@tanstack/svelte-table';
|
|
4
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
5
|
+
import { cn, type ClassValue } from 'tailwind-variants';
|
|
6
|
+
import { PhArrowSquareOut, PhCaretDown, PhCaretUp, PhCheck, PhX } from '../../icons';
|
|
7
|
+
import Checkbox from '../checkbox/checkbox.svelte';
|
|
8
|
+
import { getColumnId, type DataTableFeatures, type DataTableInstance } from './create-table';
|
|
9
|
+
import type { DataTableColumn, DataTableGroupColumn, DataTableLeafColumn } from './types';
|
|
10
|
+
|
|
11
|
+
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'class'> & {
|
|
12
|
+
table: DataTableInstance<T>;
|
|
13
|
+
columns: DataTableColumn<T>[];
|
|
14
|
+
caption?: string;
|
|
15
|
+
emptyMessage?: string;
|
|
16
|
+
class?: ClassValue;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
table,
|
|
21
|
+
columns,
|
|
22
|
+
caption,
|
|
23
|
+
emptyMessage = 'No rows found',
|
|
24
|
+
class: className,
|
|
25
|
+
...rest
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
|
|
28
|
+
function alignClass(align: DataTableColumn<T>['align']) {
|
|
29
|
+
return align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function justifyClass(align: DataTableColumn<T>['align']) {
|
|
33
|
+
return align === 'right'
|
|
34
|
+
? 'justify-end'
|
|
35
|
+
: align === 'center'
|
|
36
|
+
? 'justify-center'
|
|
37
|
+
: 'justify-start';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sortButtonDirectionClass(align: DataTableColumn<T>['align']) {
|
|
41
|
+
return align === 'right' ? 'flex-row-reverse' : 'flex-row';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function columnSizeStyle(size: number) {
|
|
45
|
+
return `width: ${size}px`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function selectionColumnSizeStyle() {
|
|
49
|
+
return 'width: 40px';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tableSizeStyle() {
|
|
53
|
+
return `width: max(100%, ${table.getTotalSize() + (isRowSelectionEnabled ? 40 : 0)}px)`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function resizeHandleStyle(header: Header<DataTableFeatures, T, CellData>) {
|
|
57
|
+
if (table.options.columnResizeMode !== 'onEnd') return undefined;
|
|
58
|
+
const deltaOffset = table.store.state.columnResizing.deltaOffset;
|
|
59
|
+
if (!header.column.getIsResizing() || deltaOffset === null) return undefined;
|
|
60
|
+
return `transform: translateX(${deltaOffset}px)`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function resizeHandleClass(headerIndex: number, headerCount: number) {
|
|
64
|
+
return cn(
|
|
65
|
+
'absolute top-0 z-10 flex h-full w-2 cursor-col-resize touch-none items-center justify-center select-none before:h-4 before:w-px before:bg-border before:content-[""]',
|
|
66
|
+
headerIndex === headerCount - 1 ? 'right-0' : '-right-1'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getHeaderSortDirection(
|
|
71
|
+
header: Header<DataTableFeatures, T, CellData>,
|
|
72
|
+
sortingState: unknown
|
|
73
|
+
) {
|
|
74
|
+
void sortingState;
|
|
75
|
+
return header.column.getIsSorted();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function getHeaderSortLabel(sortDirection: false | 'asc' | 'desc') {
|
|
79
|
+
if (sortDirection === 'asc') return 'Sorted ascending';
|
|
80
|
+
if (sortDirection === 'desc') return 'Sorted descending';
|
|
81
|
+
return 'Not sorted';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getHeaderAriaSort(sortDirection: false | 'asc' | 'desc') {
|
|
85
|
+
if (sortDirection === 'asc') return 'ascending';
|
|
86
|
+
if (sortDirection === 'desc') return 'descending';
|
|
87
|
+
return 'none';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getAllRowsSelectionState(rowSelection: unknown) {
|
|
91
|
+
void rowSelection;
|
|
92
|
+
const allRowsSelected = table.getIsAllRowsSelected();
|
|
93
|
+
const someRowsSelected = table.getIsSomeRowsSelected();
|
|
94
|
+
return allRowsSelected ? true : someRowsSelected ? 'indeterminate' : false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function getRowSelectionState(rowSelection: unknown, rowId: string) {
|
|
98
|
+
void rowSelection;
|
|
99
|
+
return table.getRow(rowId).getIsSelected();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getSelectedRowCount(rowSelection: unknown) {
|
|
103
|
+
void rowSelection;
|
|
104
|
+
return table.getSelectedRowModel().rows.length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getBooleanCellValue(value: unknown) {
|
|
108
|
+
if (value === true) return true;
|
|
109
|
+
if (value === false) return false;
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// type PinPosition = 'center' | false | 'left' | 'right';
|
|
114
|
+
function getPinningStyle(
|
|
115
|
+
column: {
|
|
116
|
+
getIsPinned(): ColumnPinningPosition;
|
|
117
|
+
getStart(position?: ColumnPinningPosition): number;
|
|
118
|
+
getAfter(position?: ColumnPinningPosition): number;
|
|
119
|
+
getIsLastColumn(position?: ColumnPinningPosition): boolean;
|
|
120
|
+
getIsFirstColumn(position?: ColumnPinningPosition): boolean;
|
|
121
|
+
},
|
|
122
|
+
isHeader = false
|
|
123
|
+
): string | undefined {
|
|
124
|
+
const isPinned = column.getIsPinned();
|
|
125
|
+
if (!isPinned) return undefined;
|
|
126
|
+
|
|
127
|
+
const zIndex = isHeader ? 15 : 1;
|
|
128
|
+
const selectionOffset = isRowSelectionEnabled ? 40 : 0;
|
|
129
|
+
|
|
130
|
+
if (isPinned === 'left') {
|
|
131
|
+
const left = column.getStart('left') + selectionOffset;
|
|
132
|
+
const shadow =
|
|
133
|
+
!isHeader && column.getIsLastColumn('left')
|
|
134
|
+
? 'box-shadow: -4px 0 4px -4px var(--compote-border) inset'
|
|
135
|
+
: undefined;
|
|
136
|
+
return ['position: sticky', `z-index: ${zIndex}`, `left: ${left}px`, shadow]
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.join('; ');
|
|
139
|
+
} else {
|
|
140
|
+
const right = column.getAfter('right');
|
|
141
|
+
const shadow =
|
|
142
|
+
!isHeader && column.getIsFirstColumn('right')
|
|
143
|
+
? 'box-shadow: 4px 0 4px -4px var(--compote-border) inset'
|
|
144
|
+
: undefined;
|
|
145
|
+
return ['position: sticky', `z-index: ${zIndex}`, `right: ${right}px`, shadow]
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
.join('; ');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function getUrlCellValue(value: unknown) {
|
|
152
|
+
if (typeof value !== 'string' || value.trim() === '') return undefined;
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function openUrlCell(value: string) {
|
|
157
|
+
window.open(value, '_blank', 'noopener,noreferrer');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function isGroupColumn(column: DataTableColumn<T>): column is DataTableGroupColumn<T> {
|
|
161
|
+
return Array.isArray(column.columns);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isLeafColumn(column: DataTableColumn<T>): column is DataTableLeafColumn<T> {
|
|
165
|
+
return !isGroupColumn(column);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function findColumnById(
|
|
169
|
+
columnId: string,
|
|
170
|
+
candidates: DataTableColumn<T>[]
|
|
171
|
+
): DataTableColumn<T> | undefined {
|
|
172
|
+
for (const column of candidates) {
|
|
173
|
+
if (isGroupColumn(column)) {
|
|
174
|
+
if ((column.id ?? column.header) === columnId) return column;
|
|
175
|
+
|
|
176
|
+
const found = findColumnById(columnId, column.columns);
|
|
177
|
+
if (found) return found;
|
|
178
|
+
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (isLeafColumn(column) && getColumnId(column) === columnId) return column;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const tableStateKey = $derived(JSON.stringify(table.store.state));
|
|
187
|
+
function trackTableState() {
|
|
188
|
+
return tableStateKey;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const rowModel = $derived.by(() => {
|
|
192
|
+
trackTableState();
|
|
193
|
+
return table.getRowModel();
|
|
194
|
+
});
|
|
195
|
+
const headerGroups = $derived.by(() => {
|
|
196
|
+
trackTableState();
|
|
197
|
+
return table.getHeaderGroups();
|
|
198
|
+
});
|
|
199
|
+
const visibleLeafColumns = $derived.by(() => {
|
|
200
|
+
trackTableState();
|
|
201
|
+
return table.getVisibleLeafColumns();
|
|
202
|
+
});
|
|
203
|
+
const visibleColumnCount = $derived(visibleLeafColumns.length);
|
|
204
|
+
const isRowSelectionEnabled = $derived(Boolean(table.options.enableRowSelection));
|
|
205
|
+
const isMultiRowSelectionEnabled = $derived(table.options.enableMultiRowSelection !== false);
|
|
206
|
+
const tableColumnCount = $derived(visibleColumnCount + (isRowSelectionEnabled ? 1 : 0));
|
|
207
|
+
const renderedColumnCount = $derived(tableColumnCount + 1);
|
|
208
|
+
const headerGroupCount = $derived(headerGroups.length);
|
|
209
|
+
const allRowsSelectionState = $derived(getAllRowsSelectionState(table.store.state.rowSelection));
|
|
210
|
+
const selectedRowCount = $derived(getSelectedRowCount(table.store.state.rowSelection));
|
|
211
|
+
const isColumnResizing = $derived(table.store.state.columnResizing.isResizingColumn !== false);
|
|
212
|
+
</script>
|
|
213
|
+
|
|
214
|
+
<div
|
|
215
|
+
class={cn(
|
|
216
|
+
'flex max-h-full min-h-0 flex-col overflow-hidden rounded-lg border border-surface-3 bg-surface-1',
|
|
217
|
+
className
|
|
218
|
+
)}
|
|
219
|
+
{...rest}
|
|
220
|
+
>
|
|
221
|
+
{#if isColumnResizing}
|
|
222
|
+
<div aria-hidden="true" class="fixed inset-0 z-50 cursor-col-resize select-none"></div>
|
|
223
|
+
{/if}
|
|
224
|
+
|
|
225
|
+
<div class="min-h-0 flex-1 overflow-auto">
|
|
226
|
+
<table class="table-fixed border-separate border-spacing-0 text-sm" style={tableSizeStyle()}>
|
|
227
|
+
<colgroup>
|
|
228
|
+
{#if isRowSelectionEnabled}
|
|
229
|
+
<col style={selectionColumnSizeStyle()} />
|
|
230
|
+
{/if}
|
|
231
|
+
{#each visibleLeafColumns as column (column.id)}
|
|
232
|
+
<col style={columnSizeStyle(column.getSize())} />
|
|
233
|
+
{/each}
|
|
234
|
+
<col />
|
|
235
|
+
</colgroup>
|
|
236
|
+
{#if caption}
|
|
237
|
+
<caption class="sr-only">{caption}</caption>
|
|
238
|
+
{/if}
|
|
239
|
+
<thead class="sticky top-0 z-20 bg-surface-2 text-left text-ink-dim">
|
|
240
|
+
{#each headerGroups as headerGroup, headerGroupIndex (headerGroup.id)}
|
|
241
|
+
{@const visibleHeaders = headerGroup.headers.filter((header) => header.colSpan > 0)}
|
|
242
|
+
<tr class="h-9">
|
|
243
|
+
{#if isRowSelectionEnabled && headerGroupIndex === 0}
|
|
244
|
+
<th
|
|
245
|
+
class="h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 text-center align-middle leading-5 font-medium"
|
|
246
|
+
style="position: sticky; left: 0; z-index: 15"
|
|
247
|
+
rowspan={headerGroupCount}
|
|
248
|
+
>
|
|
249
|
+
{#if isMultiRowSelectionEnabled}
|
|
250
|
+
<Checkbox
|
|
251
|
+
size="sm"
|
|
252
|
+
aria-label="Select all rows"
|
|
253
|
+
class="mx-auto size-4"
|
|
254
|
+
checked={allRowsSelectionState}
|
|
255
|
+
onCheckedChange={({ checked }) => table.toggleAllRowsSelected(checked === true)}
|
|
256
|
+
/>
|
|
257
|
+
{/if}
|
|
258
|
+
</th>
|
|
259
|
+
{/if}
|
|
260
|
+
{#each visibleHeaders as header, headerIndex (header.id)}
|
|
261
|
+
{@const columnDef = findColumnById(header.column.id, columns)}
|
|
262
|
+
{@const sortDirection = getHeaderSortDirection(header, table.store.state.sorting)}
|
|
263
|
+
<th
|
|
264
|
+
class={cn(
|
|
265
|
+
'relative h-9 border-b border-surface-3 bg-surface-2 px-3 py-0 align-middle leading-5 font-medium',
|
|
266
|
+
alignClass(columnDef?.align)
|
|
267
|
+
)}
|
|
268
|
+
colspan={header.colSpan}
|
|
269
|
+
aria-sort={header.column.getCanSort()
|
|
270
|
+
? getHeaderAriaSort(sortDirection)
|
|
271
|
+
: undefined}
|
|
272
|
+
style={getPinningStyle(header.column, true)}
|
|
273
|
+
>
|
|
274
|
+
{#if !header.isPlaceholder}
|
|
275
|
+
{#if header.column.getCanSort()}
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
class={cn(
|
|
279
|
+
'inline-flex max-w-full appearance-none items-center gap-1 rounded-sm border-0 bg-transparent p-0 align-middle text-sm leading-5 text-inherit outline-none hover:text-ink data-focus-visible:outline-2 data-focus-visible:outline-offset-2 data-focus-visible:outline-ring',
|
|
280
|
+
justifyClass(columnDef?.align),
|
|
281
|
+
sortButtonDirectionClass(columnDef?.align)
|
|
282
|
+
)}
|
|
283
|
+
aria-label={`${getHeaderSortLabel(sortDirection)}. Toggle sorting.`}
|
|
284
|
+
onclick={header.column.getToggleSortingHandler()}
|
|
285
|
+
>
|
|
286
|
+
<span class="min-w-0 truncate">
|
|
287
|
+
<FlexRender {header} />
|
|
288
|
+
</span>
|
|
289
|
+
<span
|
|
290
|
+
class="inline-flex size-3.5 shrink-0 items-center justify-center text-ink-dim"
|
|
291
|
+
>
|
|
292
|
+
{#if sortDirection === 'asc'}
|
|
293
|
+
<PhCaretUp class="size-3.5" />
|
|
294
|
+
{:else if sortDirection === 'desc'}
|
|
295
|
+
<PhCaretDown class="size-3.5" />
|
|
296
|
+
{/if}
|
|
297
|
+
</span>
|
|
298
|
+
</button>
|
|
299
|
+
{:else}
|
|
300
|
+
<FlexRender {header} />
|
|
301
|
+
{/if}
|
|
302
|
+
{/if}
|
|
303
|
+
{#if header.column.getCanResize()}
|
|
304
|
+
<div
|
|
305
|
+
aria-hidden="true"
|
|
306
|
+
class={resizeHandleClass(headerIndex, visibleHeaders.length)}
|
|
307
|
+
style={resizeHandleStyle(header)}
|
|
308
|
+
ondblclick={() => header.column.resetSize()}
|
|
309
|
+
onmousedown={header.getResizeHandler()}
|
|
310
|
+
ontouchstart={header.getResizeHandler()}
|
|
311
|
+
></div>
|
|
312
|
+
{/if}
|
|
313
|
+
</th>
|
|
314
|
+
{/each}
|
|
315
|
+
<th aria-hidden="true" class="h-9 border-b border-surface-3 bg-surface-2 p-0"></th>
|
|
316
|
+
</tr>
|
|
317
|
+
{/each}
|
|
318
|
+
</thead>
|
|
319
|
+
<tbody>
|
|
320
|
+
{#each rowModel.rows as row (row.id)}
|
|
321
|
+
{@const rowSelected = getRowSelectionState(table.store.state.rowSelection, row.id)}
|
|
322
|
+
<tr
|
|
323
|
+
class={cn(
|
|
324
|
+
'group/row border-b border-surface-3 last:border-b-0',
|
|
325
|
+
'[--row-bg:var(--compote-surface-1)]',
|
|
326
|
+
'hover:bg-well/60 hover:[--row-bg:color-mix(in_srgb,var(--compote-well)_60%,var(--compote-surface-1))]',
|
|
327
|
+
rowSelected &&
|
|
328
|
+
'bg-well/60 [--row-bg:color-mix(in_srgb,var(--compote-well)_60%,var(--compote-surface-1))]'
|
|
329
|
+
)}
|
|
330
|
+
>
|
|
331
|
+
{#if isRowSelectionEnabled}
|
|
332
|
+
<td
|
|
333
|
+
class="bg-(--row-bg) px-3 py-2 text-center align-middle"
|
|
334
|
+
style="position: sticky; left: 0; z-index: 1"
|
|
335
|
+
>
|
|
336
|
+
<Checkbox
|
|
337
|
+
size="sm"
|
|
338
|
+
aria-label="Select row"
|
|
339
|
+
class="mx-auto size-4"
|
|
340
|
+
checked={rowSelected}
|
|
341
|
+
disabled={!row.getCanSelect()}
|
|
342
|
+
onCheckedChange={({ checked }) => row.toggleSelected(checked === true)}
|
|
343
|
+
/>
|
|
344
|
+
</td>
|
|
345
|
+
{/if}
|
|
346
|
+
{#each row.getVisibleCells() as cell (cell.id)}
|
|
347
|
+
{@const columnDef = findColumnById(cell.column.id, columns)}
|
|
348
|
+
<td
|
|
349
|
+
class={cn(
|
|
350
|
+
'truncate px-3 py-2',
|
|
351
|
+
alignClass(columnDef?.align),
|
|
352
|
+
cell.column.getIsPinned() && 'bg-(--row-bg)'
|
|
353
|
+
)}
|
|
354
|
+
style={getPinningStyle(cell.column)}
|
|
355
|
+
>
|
|
356
|
+
{#if columnDef?.type === 'boolean'}
|
|
357
|
+
{@const value = getBooleanCellValue(cell.getValue())}
|
|
358
|
+
{#if value === true}
|
|
359
|
+
<span
|
|
360
|
+
class="inline-flex size-5 items-center justify-center text-success"
|
|
361
|
+
role="img"
|
|
362
|
+
aria-label="Yes"
|
|
363
|
+
>
|
|
364
|
+
<PhCheck class="size-4" />
|
|
365
|
+
</span>
|
|
366
|
+
{:else if value === false}
|
|
367
|
+
<span
|
|
368
|
+
class="inline-flex size-5 items-center justify-center text-danger"
|
|
369
|
+
role="img"
|
|
370
|
+
aria-label="No"
|
|
371
|
+
>
|
|
372
|
+
<PhX class="size-4" />
|
|
373
|
+
</span>
|
|
374
|
+
{:else}
|
|
375
|
+
-
|
|
376
|
+
{/if}
|
|
377
|
+
{:else if columnDef?.type === 'url'}
|
|
378
|
+
{@const value = getUrlCellValue(cell.getValue())}
|
|
379
|
+
{#if value}
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
class={cn(
|
|
383
|
+
'inline-flex max-w-full appearance-none items-center gap-1.5 rounded-sm border-0 bg-transparent p-0 align-middle leading-5 font-medium text-ink underline decoration-border decoration-dotted underline-offset-4 outline-none hover:text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
384
|
+
justifyClass(columnDef.align)
|
|
385
|
+
)}
|
|
386
|
+
onclick={() => openUrlCell(value)}
|
|
387
|
+
>
|
|
388
|
+
<PhArrowSquareOut class="size-3.5 shrink-0" />
|
|
389
|
+
</button>
|
|
390
|
+
{:else}
|
|
391
|
+
-
|
|
392
|
+
{/if}
|
|
393
|
+
{:else}
|
|
394
|
+
<FlexRender {cell} />
|
|
395
|
+
{/if}
|
|
396
|
+
</td>
|
|
397
|
+
{/each}
|
|
398
|
+
<td aria-hidden="true" class="p-0"></td>
|
|
399
|
+
</tr>
|
|
400
|
+
{:else}
|
|
401
|
+
<tr>
|
|
402
|
+
<td class="px-3 py-10 text-center text-sm text-ink-dim" colspan={renderedColumnCount}>
|
|
403
|
+
{emptyMessage}
|
|
404
|
+
</td>
|
|
405
|
+
</tr>
|
|
406
|
+
{/each}
|
|
407
|
+
</tbody>
|
|
408
|
+
</table>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div class="shrink-0 border-t border-surface-3 bg-surface-2 px-3 py-2 text-sm text-ink-dim">
|
|
412
|
+
{#if isRowSelectionEnabled}
|
|
413
|
+
{selectedRowCount} of {rowModel.rows.length} rows selected
|
|
414
|
+
{:else}
|
|
415
|
+
{rowModel.rows.length} rows
|
|
416
|
+
{/if}
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|