@marianmeres/stuic 3.7.0 → 3.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.
@@ -0,0 +1,451 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+ import type { PagingCalcResult } from "@marianmeres/paging-store";
5
+ import type { THC } from "../Thc/index.js";
6
+ import type { TranslateFn } from "../../types.js";
7
+ import { isPlainObject } from "../../utils/is-plain-object.js";
8
+ import { replaceMap } from "../../utils/replace-map.js";
9
+
10
+ // i18n ready
11
+ function t_default(
12
+ k: string,
13
+ values: false | null | undefined | Record<string, string | number> = null,
14
+ fallback: string | boolean = "",
15
+ _i18nSpanWrap: boolean = true
16
+ ) {
17
+ const m: Record<string, string> = {
18
+ previous_page: "Prev",
19
+ next_page: "Next",
20
+ page_x_of_y: "Page {page} of {pageCount}",
21
+ no_data: "No data",
22
+ select_all_rows: "Select all rows",
23
+ select_row: "Select row",
24
+ };
25
+ let out = m[k] ?? fallback ?? k;
26
+ return isPlainObject(values) ? replaceMap(out, values as any) : out;
27
+ }
28
+
29
+ export interface DataTableColumn<T = Record<string, any>> {
30
+ /** Property key to extract from row data (supports dot-notation: "data.name") */
31
+ key: string;
32
+ /** Column header label (string, HTML, component, or snippet) */
33
+ label?: THC;
34
+ /** CSS width value (e.g. "200px", "30%") */
35
+ width?: string;
36
+ /** Additional CSS class for body cells in this column */
37
+ class?: string;
38
+ /** Additional CSS class for the header cell */
39
+ classHeader?: string;
40
+ /** Text alignment */
41
+ align?: "left" | "center" | "right";
42
+ /** Hide this column in mobile card view */
43
+ hideOnMobile?: boolean;
44
+ /** Simple text formatter for cell values */
45
+ renderValue?: (value: any, row: T) => string;
46
+ }
47
+
48
+ export interface Props<T = Record<string, any>> extends Omit<
49
+ HTMLAttributes<HTMLDivElement>,
50
+ "children"
51
+ > {
52
+ /** Column definitions */
53
+ columns: DataTableColumn<T>[];
54
+ /** Array of row data objects */
55
+ data: T[];
56
+ /** Function to extract a unique ID from a row. Defaults to index. */
57
+ getRowId?: (row: T, index: number) => string | number;
58
+
59
+ /** Paging calculation result from @marianmeres/paging-store */
60
+ paging?: PagingCalcResult;
61
+ /** Callback when the user navigates to a different page (receives new offset) */
62
+ onPageChange?: (offset: number) => void;
63
+
64
+ /** Enable row selection checkboxes */
65
+ selectable?: boolean;
66
+ /** Set of selected row IDs (bindable) */
67
+ selected?: Set<string | number>;
68
+ /** Toggle row selection when clicking anywhere on the row */
69
+ selectOnRowClick?: boolean;
70
+
71
+ /** Callback when a row is clicked */
72
+ onRowClick?: (row: T, index: number) => void;
73
+
74
+ /** Show loading state (spinner overlay + reduced opacity) */
75
+ loading?: boolean;
76
+
77
+ /** Custom cell renderer snippet */
78
+ cell?: Snippet<
79
+ [{ column: DataTableColumn<T>; row: T; value: any; rowIndex: number }]
80
+ >;
81
+ /** Batch actions bar snippet (shown when items are selected) */
82
+ batchActions?: Snippet<
83
+ [
84
+ {
85
+ selected: Set<string | number>;
86
+ selectedRows: T[];
87
+ clearSelection: () => void;
88
+ },
89
+ ]
90
+ >;
91
+ /** Custom empty state snippet */
92
+ empty?: Snippet;
93
+ /** Custom mobile row card snippet */
94
+ mobileRow?: Snippet<[{ row: T; columns: DataTableColumn<T>[]; rowIndex: number }]>;
95
+ /** Default children snippet (not used directly) */
96
+ children?: Snippet;
97
+
98
+ /** Optional translate function */
99
+ t?: TranslateFn;
100
+
101
+ /** Force mobile/card layout regardless of breakpoint */
102
+ small?: boolean;
103
+
104
+ /** Skip all default styling */
105
+ unstyled?: boolean;
106
+ /** Additional CSS classes for the root container */
107
+ class?: string;
108
+ /** Bindable element reference */
109
+ el?: HTMLDivElement;
110
+ }
111
+ </script>
112
+
113
+ <script lang="ts" generics="T extends Record<string, any> = Record<string, any>">
114
+ import { twMerge } from "../../utils/tw-merge.js";
115
+ import { Breakpoint } from "../../utils/breakpoint.svelte.js";
116
+ import Spinner from "../Spinner/Spinner.svelte";
117
+ import Button from "../Button/Button.svelte";
118
+ import Thc, { isTHCNotEmpty, getTHCStringContent } from "../Thc/Thc.svelte";
119
+
120
+ let {
121
+ columns,
122
+ data,
123
+ getRowId = (_row: T, index: number) => index,
124
+ paging,
125
+ onPageChange,
126
+ selectable = false,
127
+ selected = $bindable(new Set()),
128
+ selectOnRowClick = false,
129
+ onRowClick,
130
+ loading = false,
131
+ cell,
132
+ batchActions,
133
+ empty,
134
+ mobileRow,
135
+ children,
136
+ t = t_default,
137
+ small = false,
138
+ unstyled = false,
139
+ class: classProp,
140
+ el = $bindable(),
141
+ ...rest
142
+ }: Props<T> = $props();
143
+
144
+ // --- Responsive ---
145
+ const bp = Breakpoint.instance;
146
+ let isDesktop = $derived(small ? false : bp.md);
147
+
148
+ // --- Paging ---
149
+ let showPaging = $derived(paging != null && paging.pageCount > 1);
150
+
151
+ // --- Selection ---
152
+ let allRowIds = $derived(data.map((row, i) => getRowId(row, i)));
153
+
154
+ let allOnPageSelected = $derived.by(() => {
155
+ if (!selectable || allRowIds.length === 0) return false;
156
+ return allRowIds.every((id) => selected.has(id));
157
+ });
158
+
159
+ let someOnPageSelected = $derived.by(() => {
160
+ if (!selectable || allRowIds.length === 0) return false;
161
+ return allRowIds.some((id) => selected.has(id)) && !allOnPageSelected;
162
+ });
163
+
164
+ let selectedRows = $derived.by(() => {
165
+ if (!selectable || selected.size === 0) return [] as T[];
166
+ return data.filter((row, i) => selected.has(getRowId(row, i)));
167
+ });
168
+
169
+ function toggleSelectAll() {
170
+ if (allOnPageSelected) {
171
+ const next = new Set(selected);
172
+ for (const id of allRowIds) next.delete(id);
173
+ selected = next;
174
+ } else {
175
+ const next = new Set(selected);
176
+ for (const id of allRowIds) next.add(id);
177
+ selected = next;
178
+ }
179
+ }
180
+
181
+ function toggleSelectRow(id: string | number) {
182
+ const next = new Set(selected);
183
+ if (next.has(id)) {
184
+ next.delete(id);
185
+ } else {
186
+ next.add(id);
187
+ }
188
+ selected = next;
189
+ }
190
+
191
+ function clearSelection() {
192
+ selected = new Set();
193
+ }
194
+
195
+ // --- Row click ---
196
+ function handleRowClick(row: T, index: number, e: MouseEvent) {
197
+ const target = e.target as HTMLElement;
198
+ if (
199
+ target.closest('input[type="checkbox"]') ||
200
+ target.closest("button") ||
201
+ target.closest("a")
202
+ ) {
203
+ return;
204
+ }
205
+ if (selectable && selectOnRowClick) {
206
+ toggleSelectRow(getRowId(row, index));
207
+ }
208
+ onRowClick?.(row, index);
209
+ }
210
+
211
+ // --- Cell value helpers ---
212
+ function getCellValue(row: T, column: DataTableColumn<T>): any {
213
+ return column.key.split(".").reduce((obj: any, k) => obj?.[k], row);
214
+ }
215
+
216
+ function getCellDisplay(row: T, column: DataTableColumn<T>): string {
217
+ const value = getCellValue(row, column);
218
+ if (column.renderValue) return column.renderValue(value, row);
219
+ return value == null ? "" : String(value);
220
+ }
221
+
222
+ // --- CSS ---
223
+ let rootClass = $derived(unstyled ? classProp : twMerge("stuic-data-table", classProp));
224
+
225
+ let mobileColumns = $derived(columns.filter((col) => !col.hideOnMobile));
226
+ </script>
227
+
228
+ <!-- Batch action bar -->
229
+ {#if selectable && selected.size > 0 && batchActions}
230
+ <div class={!unstyled ? "stuic-data-table-batch" : undefined}>
231
+ {@render batchActions({ selected, selectedRows, clearSelection })}
232
+ </div>
233
+ {/if}
234
+
235
+ <!-- Root container -->
236
+ <div bind:this={el} class={rootClass} {...rest}>
237
+ {#if isDesktop}
238
+ <!-- DESKTOP: TABLE -->
239
+ <div
240
+ class={!unstyled ? "stuic-data-table-wrapper" : undefined}
241
+ data-loading={!unstyled && loading ? "true" : undefined}
242
+ >
243
+ <table>
244
+ <thead>
245
+ <tr>
246
+ {#if selectable}
247
+ <th data-checkbox class="stuic-checkbox">
248
+ <input
249
+ type="checkbox"
250
+ checked={allOnPageSelected}
251
+ indeterminate={someOnPageSelected}
252
+ onchange={toggleSelectAll}
253
+ aria-label={t("select_all_rows")}
254
+ />
255
+ </th>
256
+ {/if}
257
+ {#each columns as col (col.key)}
258
+ <th
259
+ class={col.classHeader}
260
+ data-align={!unstyled && col.align ? col.align : undefined}
261
+ style={col.width ? `width: ${col.width}` : undefined}
262
+ >
263
+ {#if isTHCNotEmpty(col.label)}
264
+ <Thc thc={col.label!} />
265
+ {:else}
266
+ {col.key}
267
+ {/if}
268
+ </th>
269
+ {/each}
270
+ </tr>
271
+ </thead>
272
+ <tbody>
273
+ {#each data as row, rowIndex (getRowId(row, rowIndex))}
274
+ {@const rowId = getRowId(row, rowIndex)}
275
+ {@const isSelected = selectable && selected.has(rowId)}
276
+ <tr
277
+ data-hoverable={!unstyled ? "true" : undefined}
278
+ data-clickable={!unstyled && (onRowClick || selectOnRowClick)
279
+ ? "true"
280
+ : undefined}
281
+ data-selected={!unstyled && isSelected ? "true" : undefined}
282
+ onclick={(e) => handleRowClick(row, rowIndex, e)}
283
+ >
284
+ {#if selectable}
285
+ <td data-checkbox class="stuic-checkbox">
286
+ <input
287
+ type="checkbox"
288
+ checked={isSelected}
289
+ onchange={() => toggleSelectRow(rowId)}
290
+ aria-label={t("select_row")}
291
+ />
292
+ </td>
293
+ {/if}
294
+ {#each columns as col (col.key)}
295
+ {@const value = getCellValue(row, col)}
296
+ <td
297
+ class={col.class}
298
+ data-align={!unstyled && col.align ? col.align : undefined}
299
+ >
300
+ {#if cell}
301
+ {@render cell({
302
+ column: col,
303
+ row,
304
+ value,
305
+ rowIndex,
306
+ })}
307
+ {:else}
308
+ {getCellDisplay(row, col)}
309
+ {/if}
310
+ </td>
311
+ {/each}
312
+ </tr>
313
+ {:else}
314
+ <tr>
315
+ <td
316
+ colspan={columns.length + (selectable ? 1 : 0)}
317
+ class={!unstyled ? "stuic-data-table-empty" : undefined}
318
+ >
319
+ {#if empty}
320
+ {@render empty()}
321
+ {:else}
322
+ {t("no_data")}
323
+ {/if}
324
+ </td>
325
+ </tr>
326
+ {/each}
327
+ </tbody>
328
+ </table>
329
+ </div>
330
+ {:else}
331
+ <!-- MOBILE: CARDS -->
332
+ <div
333
+ class={!unstyled ? "stuic-data-table-cards" : undefined}
334
+ data-loading={!unstyled && loading ? "true" : undefined}
335
+ >
336
+ {#each data as row, rowIndex (getRowId(row, rowIndex))}
337
+ {@const rowId = getRowId(row, rowIndex)}
338
+ {@const isSelected = selectable && selected.has(rowId)}
339
+ {#if mobileRow}
340
+ {@render mobileRow({
341
+ row,
342
+ columns: mobileColumns,
343
+ rowIndex,
344
+ })}
345
+ {:else}
346
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
347
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
348
+ <div
349
+ class={!unstyled ? "stuic-data-table-card" : undefined}
350
+ data-clickable={!unstyled && (onRowClick || selectOnRowClick)
351
+ ? "true"
352
+ : undefined}
353
+ data-selected={!unstyled && isSelected ? "true" : undefined}
354
+ role={onRowClick || selectOnRowClick ? "button" : undefined}
355
+ tabindex={onRowClick || selectOnRowClick ? 0 : undefined}
356
+ onclick={(e) => handleRowClick(row, rowIndex, e)}
357
+ onkeydown={(e) => {
358
+ if (
359
+ (onRowClick || selectOnRowClick) &&
360
+ (e.key === "Enter" || e.key === " ")
361
+ ) {
362
+ e.preventDefault();
363
+ if (selectable && selectOnRowClick) {
364
+ toggleSelectRow(rowId);
365
+ }
366
+ onRowClick?.(row, rowIndex);
367
+ }
368
+ }}
369
+ >
370
+ {#if selectable}
371
+ <div class="stuic-checkbox flex items-center gap-2 mb-1">
372
+ <input
373
+ type="checkbox"
374
+ checked={isSelected}
375
+ onchange={() => toggleSelectRow(rowId)}
376
+ aria-label={t("select_row")}
377
+ />
378
+ </div>
379
+ {/if}
380
+ {#each mobileColumns as col (col.key)}
381
+ {@const value = getCellValue(row, col)}
382
+ <div class={!unstyled ? "stuic-data-table-card-row" : undefined}>
383
+ <span class={!unstyled ? "stuic-data-table-card-label" : undefined}>
384
+ {#if isTHCNotEmpty(col.label)}
385
+ {getTHCStringContent(col.label) || col.key}
386
+ {:else}
387
+ {col.key}
388
+ {/if}
389
+ </span>
390
+ <span class={!unstyled ? "stuic-data-table-card-value" : undefined}>
391
+ {#if cell}
392
+ {@render cell({
393
+ column: col,
394
+ row,
395
+ value,
396
+ rowIndex,
397
+ })}
398
+ {:else}
399
+ {getCellDisplay(row, col)}
400
+ {/if}
401
+ </span>
402
+ </div>
403
+ {/each}
404
+ </div>
405
+ {/if}
406
+ {:else}
407
+ <div class={!unstyled ? "stuic-data-table-empty" : undefined}>
408
+ {#if empty}
409
+ {@render empty()}
410
+ {:else}
411
+ No data
412
+ {/if}
413
+ </div>
414
+ {/each}
415
+ </div>
416
+ {/if}
417
+
418
+ <!-- Loading spinner overlay -->
419
+ {#if loading}
420
+ <div class={!unstyled ? "stuic-data-table-loading" : undefined}>
421
+ <Spinner />
422
+ </div>
423
+ {/if}
424
+
425
+ <!-- Paging -->
426
+ {#if showPaging && paging}
427
+ <div class={!unstyled ? "stuic-data-table-paging" : undefined}>
428
+ <Button
429
+ variant="ghost"
430
+ size="sm"
431
+ disabled={!paging.hasPrevious}
432
+ onclick={() => onPageChange?.(paging!.previousOffset)}
433
+ aria-label={t("previous_page")}
434
+ >
435
+ &lsaquo; {t("previous_page")}
436
+ </Button>
437
+ <span class={!unstyled ? "stuic-data-table-paging-info" : undefined}>
438
+ {t("page_x_of_y", { page: paging.currentPage, pageCount: paging.pageCount })}
439
+ </span>
440
+ <Button
441
+ variant="ghost"
442
+ size="sm"
443
+ disabled={!paging.hasNext}
444
+ onclick={() => onPageChange?.(paging!.nextOffset)}
445
+ aria-label={t("next_page")}
446
+ >
447
+ {t("next_page")} &rsaquo;
448
+ </Button>
449
+ </div>
450
+ {/if}
451
+ </div>
@@ -0,0 +1,106 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { PagingCalcResult } from "@marianmeres/paging-store";
4
+ import type { THC } from "../Thc/index.js";
5
+ import type { TranslateFn } from "../../types.js";
6
+ export interface DataTableColumn<T = Record<string, any>> {
7
+ /** Property key to extract from row data (supports dot-notation: "data.name") */
8
+ key: string;
9
+ /** Column header label (string, HTML, component, or snippet) */
10
+ label?: THC;
11
+ /** CSS width value (e.g. "200px", "30%") */
12
+ width?: string;
13
+ /** Additional CSS class for body cells in this column */
14
+ class?: string;
15
+ /** Additional CSS class for the header cell */
16
+ classHeader?: string;
17
+ /** Text alignment */
18
+ align?: "left" | "center" | "right";
19
+ /** Hide this column in mobile card view */
20
+ hideOnMobile?: boolean;
21
+ /** Simple text formatter for cell values */
22
+ renderValue?: (value: any, row: T) => string;
23
+ }
24
+ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
25
+ /** Column definitions */
26
+ columns: DataTableColumn<T>[];
27
+ /** Array of row data objects */
28
+ data: T[];
29
+ /** Function to extract a unique ID from a row. Defaults to index. */
30
+ getRowId?: (row: T, index: number) => string | number;
31
+ /** Paging calculation result from @marianmeres/paging-store */
32
+ paging?: PagingCalcResult;
33
+ /** Callback when the user navigates to a different page (receives new offset) */
34
+ onPageChange?: (offset: number) => void;
35
+ /** Enable row selection checkboxes */
36
+ selectable?: boolean;
37
+ /** Set of selected row IDs (bindable) */
38
+ selected?: Set<string | number>;
39
+ /** Toggle row selection when clicking anywhere on the row */
40
+ selectOnRowClick?: boolean;
41
+ /** Callback when a row is clicked */
42
+ onRowClick?: (row: T, index: number) => void;
43
+ /** Show loading state (spinner overlay + reduced opacity) */
44
+ loading?: boolean;
45
+ /** Custom cell renderer snippet */
46
+ cell?: Snippet<[
47
+ {
48
+ column: DataTableColumn<T>;
49
+ row: T;
50
+ value: any;
51
+ rowIndex: number;
52
+ }
53
+ ]>;
54
+ /** Batch actions bar snippet (shown when items are selected) */
55
+ batchActions?: Snippet<[
56
+ {
57
+ selected: Set<string | number>;
58
+ selectedRows: T[];
59
+ clearSelection: () => void;
60
+ }
61
+ ]>;
62
+ /** Custom empty state snippet */
63
+ empty?: Snippet;
64
+ /** Custom mobile row card snippet */
65
+ mobileRow?: Snippet<[{
66
+ row: T;
67
+ columns: DataTableColumn<T>[];
68
+ rowIndex: number;
69
+ }]>;
70
+ /** Default children snippet (not used directly) */
71
+ children?: Snippet;
72
+ /** Optional translate function */
73
+ t?: TranslateFn;
74
+ /** Force mobile/card layout regardless of breakpoint */
75
+ small?: boolean;
76
+ /** Skip all default styling */
77
+ unstyled?: boolean;
78
+ /** Additional CSS classes for the root container */
79
+ class?: string;
80
+ /** Bindable element reference */
81
+ el?: HTMLDivElement;
82
+ }
83
+ declare function $$render<T extends Record<string, any> = Record<string, any>>(): {
84
+ props: Props<T>;
85
+ exports: {};
86
+ bindings: "el" | "selected";
87
+ slots: {};
88
+ events: {};
89
+ };
90
+ declare class __sveltets_Render<T extends Record<string, any> = Record<string, any>> {
91
+ props(): ReturnType<typeof $$render<T>>['props'];
92
+ events(): ReturnType<typeof $$render<T>>['events'];
93
+ slots(): ReturnType<typeof $$render<T>>['slots'];
94
+ bindings(): "el" | "selected";
95
+ exports(): {};
96
+ }
97
+ interface $$IsomorphicComponent {
98
+ new <T extends Record<string, any> = Record<string, any>>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
99
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
100
+ } & ReturnType<__sveltets_Render<T>['exports']>;
101
+ <T extends Record<string, any> = Record<string, any>>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
102
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
103
+ }
104
+ declare const DataTable: $$IsomorphicComponent;
105
+ type DataTable<T extends Record<string, any> = Record<string, any>> = InstanceType<typeof DataTable<T>>;
106
+ export default DataTable;
@@ -0,0 +1,151 @@
1
+ # DataTable
2
+
3
+ A responsive data table component with configurable columns, paging, batch selection,
4
+ loading state, and mobile card layout.
5
+
6
+ ## Usage
7
+
8
+ ### Basic
9
+
10
+ ```svelte
11
+ <script lang="ts">
12
+ import { DataTable, type DataTableColumn } from "@marianmeres/stuic";
13
+
14
+ const columns: DataTableColumn[] = [
15
+ { key: "id", label: "ID", width: "80px" },
16
+ { key: "name", label: "Name" },
17
+ { key: "email", label: "Email" },
18
+ ];
19
+
20
+ const data = [
21
+ { id: 1, name: "Alice", email: "alice@example.com" },
22
+ { id: 2, name: "Bob", email: "bob@example.com" },
23
+ ];
24
+ </script>
25
+
26
+ <DataTable {columns} {data} />
27
+ ```
28
+
29
+ ### With Paging
30
+
31
+ ```svelte
32
+ <DataTable
33
+ {columns}
34
+ {data}
35
+ page={1}
36
+ pageSize={20}
37
+ totalItems={100}
38
+ onPageChange={(p) => fetchPage(p)}
39
+ />
40
+ ```
41
+
42
+ ### With Selection
43
+
44
+ ```svelte
45
+ <script lang="ts">
46
+ let selected = $state(new Set<string | number>());
47
+ </script>
48
+
49
+ <DataTable
50
+ {columns}
51
+ {data}
52
+ selectable
53
+ bind:selected
54
+ getRowId={(row) => row.id}
55
+ >
56
+ {#snippet batchActions({ selected, clearSelection })}
57
+ <span>{selected.size} selected</span>
58
+ <Button onclick={() => deleteSelected(selected)}>Delete</Button>
59
+ <Button variant="ghost" onclick={clearSelection}>Clear</Button>
60
+ {/snippet}
61
+ </DataTable>
62
+ ```
63
+
64
+ ### Custom Cell Rendering
65
+
66
+ ```svelte
67
+ <DataTable {columns} {data}>
68
+ {#snippet cell({ column, row, value })}
69
+ {#if column.key === "status"}
70
+ <span class="badge">{value}</span>
71
+ {:else}
72
+ {value}
73
+ {/if}
74
+ {/snippet}
75
+ </DataTable>
76
+ ```
77
+
78
+ ### Custom Mobile Layout
79
+
80
+ ```svelte
81
+ <DataTable {columns} {data}>
82
+ {#snippet mobileRow({ row })}
83
+ <div class="p-3 border rounded">
84
+ <strong>{row.name}</strong>
85
+ <p>{row.email}</p>
86
+ </div>
87
+ {/snippet}
88
+ </DataTable>
89
+ ```
90
+
91
+ ## Props
92
+
93
+ | Prop | Type | Default | Description |
94
+ |------|------|---------|-------------|
95
+ | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
96
+ | `data` | `T[]` | required | Array of row data |
97
+ | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
98
+ | `page` | `number` | - | Current page (1-based) |
99
+ | `pageSize` | `number` | - | Rows per page |
100
+ | `totalItems` | `number` | - | Total items count |
101
+ | `onPageChange` | `(page: number) => void` | - | Page change callback |
102
+ | `selectable` | `boolean` | `false` | Enable selection checkboxes |
103
+ | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
104
+ | `onRowClick` | `(row, index) => void` | - | Row click callback |
105
+ | `loading` | `boolean` | `false` | Show loading overlay |
106
+ | `cell` | `Snippet` | - | Custom cell renderer |
107
+ | `batchActions` | `Snippet` | - | Batch action bar content |
108
+ | `empty` | `Snippet` | - | Custom empty state |
109
+ | `mobileRow` | `Snippet` | - | Custom mobile row card |
110
+ | `unstyled` | `boolean` | `false` | Skip default styling |
111
+ | `class` | `string` | - | Additional CSS classes |
112
+ | `el` | `HTMLDivElement` | - | Bindable element ref |
113
+
114
+ ## DataTableColumn
115
+
116
+ | Property | Type | Default | Description |
117
+ |----------|------|---------|-------------|
118
+ | `key` | `string` | required | Data property key (supports dot-notation) |
119
+ | `label` | `THC` | `key` | Column header content |
120
+ | `width` | `string` | - | CSS width value |
121
+ | `class` | `string` | - | Cell CSS class |
122
+ | `classHeader` | `string` | - | Header cell CSS class |
123
+ | `align` | `"left" \| "center" \| "right"` | `"left"` | Text alignment |
124
+ | `hideOnMobile` | `boolean` | `false` | Hide in mobile view |
125
+ | `renderValue` | `(value, row) => string` | - | Value formatter |
126
+
127
+ ## CSS Variables
128
+
129
+ | Variable | Default | Description |
130
+ |----------|---------|-------------|
131
+ | `--stuic-data-table-radius` | `var(--radius-md)` | Border radius |
132
+ | `--stuic-data-table-border-color` | `var(--stuic-color-border)` | Border color |
133
+ | `--stuic-data-table-header-bg` | `var(--stuic-color-muted)` | Header background |
134
+ | `--stuic-data-table-header-color` | `var(--stuic-color-muted-foreground)` | Header text |
135
+ | `--stuic-data-table-header-font-size` | `0.875rem` | Header font size |
136
+ | `--stuic-data-table-header-font-weight` | `var(--font-weight-semibold)` | Header font weight |
137
+ | `--stuic-data-table-header-padding-x` | `0.75rem` | Header horizontal padding |
138
+ | `--stuic-data-table-header-padding-y` | `0.5rem` | Header vertical padding |
139
+ | `--stuic-data-table-row-bg` | `transparent` | Row background |
140
+ | `--stuic-data-table-row-bg-hover` | `var(--stuic-color-muted)` | Row hover background |
141
+ | `--stuic-data-table-row-bg-selected` | `color-mix(primary 10%)` | Selected row background |
142
+ | `--stuic-data-table-row-border-color` | `var(--stuic-color-border)` | Row border color |
143
+ | `--stuic-data-table-cell-padding-x` | `0.75rem` | Cell horizontal padding |
144
+ | `--stuic-data-table-cell-padding-y` | `0.75rem` | Cell vertical padding |
145
+ | `--stuic-data-table-cell-font-size` | `0.875rem` | Cell font size |
146
+ | `--stuic-data-table-loading-opacity` | `0.5` | Loading state opacity |
147
+ | `--stuic-data-table-card-bg` | `var(--stuic-color-background)` | Mobile card background |
148
+ | `--stuic-data-table-card-border-color` | `var(--stuic-color-border)` | Mobile card border |
149
+ | `--stuic-data-table-card-radius` | `var(--radius-md)` | Mobile card radius |
150
+ | `--stuic-data-table-card-padding` | `0.75rem` | Mobile card padding |
151
+ | `--stuic-data-table-card-gap` | `0.5rem` | Gap between mobile cards |
@@ -0,0 +1,303 @@
1
+ /* ============================================================================
2
+ DATA TABLE COMPONENT TOKENS
3
+ Override globally: :root { --stuic-data-table-radius: 0; }
4
+ Override locally: <DataTable style="--stuic-data-table-radius: 0;">
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ /* Structure */
10
+ --stuic-data-table-radius: var(--radius-md);
11
+ --stuic-data-table-border-width: 1px;
12
+ --stuic-data-table-border-color: var(--stuic-color-border);
13
+ --stuic-data-table-transition: 150ms;
14
+
15
+ /* Header */
16
+ --stuic-data-table-header-bg: var(--stuic-color-muted);
17
+ --stuic-data-table-header-color: var(--stuic-color-muted-foreground);
18
+ --stuic-data-table-header-font-size: 0.875rem;
19
+ --stuic-data-table-header-font-weight: var(--font-weight-semibold);
20
+ --stuic-data-table-header-padding-x: 0.75rem;
21
+ --stuic-data-table-header-padding-y: 0.5rem;
22
+
23
+ /* Row */
24
+ --stuic-data-table-row-bg: transparent;
25
+ --stuic-data-table-row-bg-hover: var(--stuic-color-muted);
26
+ --stuic-data-table-row-bg-selected: color-mix(in srgb, var(--stuic-color-primary) 10%, var(--stuic-color-background));
27
+ --stuic-data-table-row-border-color: var(--stuic-color-border);
28
+
29
+ /* Cell */
30
+ --stuic-data-table-cell-padding-x: 0.75rem;
31
+ --stuic-data-table-cell-padding-y: 0.75rem;
32
+ --stuic-data-table-cell-font-size: 0.875rem;
33
+
34
+ /* Paging */
35
+ --stuic-data-table-paging-gap: 0.5rem;
36
+ --stuic-data-table-paging-padding-y: 0.75rem;
37
+
38
+ /* Batch action bar */
39
+ --stuic-data-table-batch-bg: var(--stuic-color-muted);
40
+ --stuic-data-table-batch-padding-x: 0.75rem;
41
+ --stuic-data-table-batch-padding-y: 0.5rem;
42
+
43
+ /* Checkbox column */
44
+ --stuic-data-table-checkbox-width: 3rem;
45
+
46
+ /* Loading overlay */
47
+ --stuic-data-table-loading-opacity: 0.5;
48
+
49
+ /* Mobile card */
50
+ --stuic-data-table-card-bg: var(--stuic-color-background);
51
+ --stuic-data-table-card-border-color: var(--stuic-color-border);
52
+ --stuic-data-table-card-radius: var(--radius-md);
53
+ --stuic-data-table-card-padding: 0.75rem;
54
+ --stuic-data-table-card-gap: 0.5rem;
55
+ --stuic-data-table-card-bg-selected: color-mix(in srgb, var(--stuic-color-primary) 10%, var(--stuic-color-background));
56
+ }
57
+
58
+ @layer components {
59
+ /* ============================================================================
60
+ CONTAINER
61
+ ============================================================================ */
62
+
63
+ .stuic-data-table {
64
+ position: relative;
65
+ width: 100%;
66
+ }
67
+
68
+ /* ============================================================================
69
+ TABLE WRAPPER (desktop)
70
+ ============================================================================ */
71
+
72
+ .stuic-data-table-wrapper {
73
+ overflow-x: auto;
74
+ border-radius: var(--stuic-data-table-radius);
75
+ border: var(--stuic-data-table-border-width) solid var(--stuic-data-table-border-color);
76
+ transition: opacity var(--stuic-data-table-transition);
77
+ }
78
+
79
+ .stuic-data-table-wrapper[data-loading="true"] {
80
+ opacity: var(--stuic-data-table-loading-opacity);
81
+ }
82
+
83
+ .stuic-data-table table {
84
+ width: 100%;
85
+ border-collapse: collapse;
86
+ table-layout: fixed;
87
+ }
88
+
89
+ /* ============================================================================
90
+ HEADER
91
+ ============================================================================ */
92
+
93
+ .stuic-data-table thead {
94
+ background: var(--stuic-data-table-header-bg);
95
+ }
96
+
97
+ .stuic-data-table th {
98
+ padding: var(--stuic-data-table-header-padding-y) var(--stuic-data-table-header-padding-x);
99
+ font-size: var(--stuic-data-table-header-font-size);
100
+ font-weight: var(--stuic-data-table-header-font-weight);
101
+ color: var(--stuic-data-table-header-color);
102
+ text-align: left;
103
+ white-space: nowrap;
104
+ border-bottom: var(--stuic-data-table-border-width) solid
105
+ var(--stuic-data-table-border-color);
106
+ }
107
+
108
+ .stuic-data-table th[data-align="center"] {
109
+ text-align: center;
110
+ }
111
+
112
+ .stuic-data-table th[data-align="right"] {
113
+ text-align: right;
114
+ }
115
+
116
+ /* ============================================================================
117
+ ROW
118
+ ============================================================================ */
119
+
120
+ .stuic-data-table tbody tr {
121
+ background: var(--stuic-data-table-row-bg);
122
+ border-bottom: var(--stuic-data-table-border-width) solid
123
+ var(--stuic-data-table-row-border-color);
124
+ transition: background var(--stuic-data-table-transition);
125
+ }
126
+
127
+ .stuic-data-table tbody tr:last-child {
128
+ border-bottom: none;
129
+ }
130
+
131
+ .stuic-data-table tbody tr[data-hoverable="true"]:hover {
132
+ background: var(--stuic-data-table-row-bg-hover);
133
+ }
134
+
135
+ .stuic-data-table tbody tr[data-clickable="true"] {
136
+ cursor: pointer;
137
+ }
138
+
139
+ .stuic-data-table tbody tr[data-selected="true"] {
140
+ background: var(--stuic-data-table-row-bg-selected);
141
+ }
142
+
143
+ .stuic-data-table tbody tr[data-selected="true"][data-hoverable="true"]:hover {
144
+ background: color-mix(
145
+ in srgb,
146
+ var(--stuic-color-primary) 18%,
147
+ var(--stuic-color-background)
148
+ );
149
+ }
150
+
151
+ /* ============================================================================
152
+ CELL
153
+ ============================================================================ */
154
+
155
+ .stuic-data-table td {
156
+ padding: var(--stuic-data-table-cell-padding-y) var(--stuic-data-table-cell-padding-x);
157
+ font-size: var(--stuic-data-table-cell-font-size);
158
+ overflow: hidden;
159
+ text-overflow: ellipsis;
160
+ white-space: nowrap;
161
+ }
162
+
163
+ .stuic-data-table td[data-align="center"] {
164
+ text-align: center;
165
+ }
166
+
167
+ .stuic-data-table td[data-align="right"] {
168
+ text-align: right;
169
+ }
170
+
171
+ /* Checkbox cell */
172
+ .stuic-data-table th[data-checkbox],
173
+ .stuic-data-table td[data-checkbox] {
174
+ width: var(--stuic-data-table-checkbox-width);
175
+ text-align: center;
176
+ padding-left: 0.5rem;
177
+ padding-right: 0.5rem;
178
+ line-height: 0;
179
+ }
180
+
181
+ /* ============================================================================
182
+ PAGING
183
+ ============================================================================ */
184
+
185
+ .stuic-data-table-paging {
186
+ display: flex;
187
+ align-items: center;
188
+ justify-content: center;
189
+ gap: var(--stuic-data-table-paging-gap);
190
+ padding-top: var(--stuic-data-table-paging-padding-y);
191
+ }
192
+
193
+ .stuic-data-table-paging-info {
194
+ font-size: var(--stuic-data-table-cell-font-size);
195
+ color: var(--stuic-data-table-header-color);
196
+ white-space: nowrap;
197
+ }
198
+
199
+ /* ============================================================================
200
+ BATCH ACTION BAR
201
+ ============================================================================ */
202
+
203
+ .stuic-data-table-batch {
204
+ display: flex;
205
+ align-items: center;
206
+ gap: var(--stuic-data-table-paging-gap);
207
+ padding: var(--stuic-data-table-batch-padding-y) var(--stuic-data-table-batch-padding-x);
208
+ background: var(--stuic-data-table-batch-bg);
209
+ border-radius: var(--stuic-data-table-radius);
210
+ margin-bottom: 0.5rem;
211
+ }
212
+
213
+ /* ============================================================================
214
+ LOADING SPINNER OVERLAY
215
+ ============================================================================ */
216
+
217
+ .stuic-data-table-loading {
218
+ position: absolute;
219
+ inset: 0;
220
+ display: flex;
221
+ align-items: center;
222
+ justify-content: center;
223
+ pointer-events: none;
224
+ z-index: 1;
225
+ }
226
+
227
+ /* ============================================================================
228
+ EMPTY STATE
229
+ ============================================================================ */
230
+
231
+ .stuic-data-table-empty {
232
+ padding: 2rem;
233
+ color: var(--stuic-data-table-header-color);
234
+ font-size: var(--stuic-data-table-cell-font-size);
235
+ text-align: center;
236
+ }
237
+
238
+ /* When empty state is not inside a table cell (mobile cards) */
239
+ div.stuic-data-table-empty {
240
+ display: flex;
241
+ align-items: center;
242
+ justify-content: center;
243
+ }
244
+
245
+ /* ============================================================================
246
+ MOBILE CARDS
247
+ ============================================================================ */
248
+
249
+ .stuic-data-table-cards {
250
+ display: flex;
251
+ flex-direction: column;
252
+ gap: var(--stuic-data-table-card-gap);
253
+ transition: opacity var(--stuic-data-table-transition);
254
+ }
255
+
256
+ .stuic-data-table-cards[data-loading="true"] {
257
+ opacity: var(--stuic-data-table-loading-opacity);
258
+ }
259
+
260
+ .stuic-data-table-card {
261
+ background: var(--stuic-data-table-card-bg);
262
+ border: var(--stuic-data-table-border-width) solid var(--stuic-data-table-card-border-color);
263
+ border-radius: var(--stuic-data-table-card-radius);
264
+ padding: var(--stuic-data-table-card-padding);
265
+ transition: background var(--stuic-data-table-transition);
266
+ }
267
+
268
+ .stuic-data-table-card[data-clickable="true"] {
269
+ cursor: pointer;
270
+ }
271
+
272
+ .stuic-data-table-card[data-clickable="true"]:hover {
273
+ background: var(--stuic-data-table-row-bg-hover);
274
+ }
275
+
276
+ .stuic-data-table-card[data-selected="true"] {
277
+ background: var(--stuic-data-table-card-bg-selected);
278
+ }
279
+
280
+ .stuic-data-table-card-row {
281
+ display: flex;
282
+ justify-content: space-between;
283
+ align-items: baseline;
284
+ gap: 0.5rem;
285
+ padding: 0.25rem 0;
286
+ }
287
+
288
+ .stuic-data-table-card-label {
289
+ font-size: var(--stuic-data-table-header-font-size);
290
+ font-weight: var(--stuic-data-table-header-font-weight);
291
+ color: var(--stuic-data-table-header-color);
292
+ flex-shrink: 0;
293
+ }
294
+
295
+ .stuic-data-table-card-value {
296
+ font-size: var(--stuic-data-table-cell-font-size);
297
+ text-align: right;
298
+ overflow: hidden;
299
+ text-overflow: ellipsis;
300
+ white-space: nowrap;
301
+ min-width: 0;
302
+ }
303
+ }
@@ -0,0 +1 @@
1
+ export { default as DataTable, type Props as DataTableProps, type DataTableColumn, } from "./DataTable.svelte";
@@ -0,0 +1 @@
1
+ export { default as DataTable, } from "./DataTable.svelte";
package/dist/index.css CHANGED
@@ -32,6 +32,7 @@ In practice:
32
32
  @import "./components/Collapsible/index.css";
33
33
  @import "./components/Carousel/index.css";
34
34
  @import "./components/CommandMenu/index.css";
35
+ @import "./components/DataTable/index.css";
35
36
  @import "./components/DismissibleMessage/index.css";
36
37
  @import "./components/DropdownMenu/index.css";
37
38
  @import "./components/Input/index.css";
package/dist/index.d.ts CHANGED
@@ -33,6 +33,7 @@ export * from "./components/Carousel/index.js";
33
33
  export * from "./components/Collapsible/index.js";
34
34
  export * from "./components/ColorScheme/index.js";
35
35
  export * from "./components/CommandMenu/index.js";
36
+ export * from "./components/DataTable/index.js";
36
37
  export * from "./components/DismissibleMessage/index.js";
37
38
  export * from "./components/Drawer/index.js";
38
39
  export * from "./components/DropdownMenu/index.js";
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export * from "./components/Carousel/index.js";
34
34
  export * from "./components/Collapsible/index.js";
35
35
  export * from "./components/ColorScheme/index.js";
36
36
  export * from "./components/CommandMenu/index.js";
37
+ export * from "./components/DataTable/index.js";
37
38
  export * from "./components/DismissibleMessage/index.js";
38
39
  export * from "./components/Drawer/index.js";
39
40
  export * from "./components/DropdownMenu/index.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.7.0",
3
+ "version": "3.8.0",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.test.*",
@@ -65,6 +65,7 @@
65
65
  "dependencies": {
66
66
  "@marianmeres/clog": "^3.15.2",
67
67
  "@marianmeres/item-collection": "^1.3.5",
68
+ "@marianmeres/paging-store": "^2.0.2",
68
69
  "@marianmeres/parse-boolean": "^2.0.5",
69
70
  "@marianmeres/ticker": "^1.16.5",
70
71
  "esm-env": "^1.2.2",