@marianmeres/stuic 3.66.0 → 3.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/dist/actions/autoscroll.d.ts +7 -0
  2. package/dist/actions/autoscroll.js +7 -0
  3. package/dist/actions/focus-trap.d.ts +7 -0
  4. package/dist/actions/focus-trap.js +8 -3
  5. package/dist/actions/typeahead.svelte.js +40 -4
  6. package/dist/components/Carousel/Carousel.svelte +9 -2
  7. package/dist/components/Carousel/README.md +8 -2
  8. package/dist/components/Cart/Cart.svelte +3 -0
  9. package/dist/components/Cart/README.md +18 -1
  10. package/dist/components/Checkout/CheckoutOrderReview.svelte +4 -14
  11. package/dist/components/Checkout/README.md +184 -0
  12. package/dist/components/Checkout/_internal/checkout-utils.d.ts +6 -0
  13. package/dist/components/Checkout/_internal/checkout-utils.js +24 -0
  14. package/dist/components/Checkout/index.d.ts +1 -1
  15. package/dist/components/Checkout/index.js +1 -1
  16. package/dist/components/CommandMenu/CommandMenu.svelte +23 -7
  17. package/dist/components/CommandMenu/CommandMenu.svelte.d.ts +2 -0
  18. package/dist/components/CronInput/CronInput.svelte +44 -9
  19. package/dist/components/CronInput/CronInput.svelte.d.ts +2 -0
  20. package/dist/components/CronInput/README.md +145 -0
  21. package/dist/components/CronInput/cron-next-run.svelte.d.ts +11 -0
  22. package/dist/components/CronInput/cron-next-run.svelte.js +11 -0
  23. package/dist/components/CronInput/index.css +0 -8
  24. package/dist/components/DataTable/DataTable.svelte +99 -62
  25. package/dist/components/DataTable/DataTable.svelte.d.ts +13 -3
  26. package/dist/components/DataTable/README.md +79 -25
  27. package/dist/components/DataTable/index.css +7 -0
  28. package/dist/components/DropdownMenu/DropdownMenu.svelte +43 -26
  29. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +5 -1
  30. package/dist/components/DropdownMenu/README.md +37 -9
  31. package/dist/components/Input/FieldAssets.svelte +9 -7
  32. package/dist/components/Input/FieldAssets.svelte.d.ts +3 -7
  33. package/dist/components/Input/FieldFile.svelte +13 -7
  34. package/dist/components/Input/FieldFile.svelte.d.ts +4 -7
  35. package/dist/components/Input/FieldInput.svelte +10 -8
  36. package/dist/components/Input/FieldInput.svelte.d.ts +3 -8
  37. package/dist/components/Input/FieldInputLocalized.svelte +8 -7
  38. package/dist/components/Input/FieldInputLocalized.svelte.d.ts +2 -7
  39. package/dist/components/Input/FieldKeyValues.svelte +8 -7
  40. package/dist/components/Input/FieldKeyValues.svelte.d.ts +2 -7
  41. package/dist/components/Input/FieldLikeButton.svelte +9 -7
  42. package/dist/components/Input/FieldLikeButton.svelte.d.ts +3 -7
  43. package/dist/components/Input/FieldObject.svelte +8 -7
  44. package/dist/components/Input/FieldObject.svelte.d.ts +2 -7
  45. package/dist/components/Input/FieldOptions.svelte +9 -7
  46. package/dist/components/Input/FieldOptions.svelte.d.ts +3 -7
  47. package/dist/components/Input/FieldPhoneNumber.svelte +7 -8
  48. package/dist/components/Input/FieldPhoneNumber.svelte.d.ts +3 -8
  49. package/dist/components/Input/FieldSelect.svelte +9 -8
  50. package/dist/components/Input/FieldSelect.svelte.d.ts +3 -8
  51. package/dist/components/Input/FieldSwitch.svelte +9 -7
  52. package/dist/components/Input/FieldSwitch.svelte.d.ts +3 -7
  53. package/dist/components/Input/FieldTextarea.svelte +7 -8
  54. package/dist/components/Input/FieldTextarea.svelte.d.ts +3 -8
  55. package/dist/components/Input/README.md +20 -0
  56. package/dist/components/Input/_internal/InputWrap.svelte +2 -10
  57. package/dist/components/Input/_internal/InputWrap.svelte.d.ts +2 -10
  58. package/dist/components/Input/types.d.ts +28 -0
  59. package/dist/components/Nav/Nav.svelte +5 -4
  60. package/dist/components/Nav/Nav.svelte.d.ts +2 -2
  61. package/dist/components/Nav/README.md +2 -2
  62. package/dist/components/Nav/index.css +4 -0
  63. package/dist/components/Tree/README.md +189 -0
  64. package/dist/components/Tree/Tree.svelte +46 -2
  65. package/dist/components/Tree/Tree.svelte.d.ts +5 -0
  66. package/dist/utils/input-history.svelte.d.ts +12 -0
  67. package/dist/utils/input-history.svelte.js +12 -0
  68. package/dist/utils/observe-exists.svelte.d.ts +1 -0
  69. package/dist/utils/observe-exists.svelte.js +11 -3
  70. package/dist/utils/switch.svelte.d.ts +12 -0
  71. package/dist/utils/switch.svelte.js +12 -1
  72. package/docs/architecture.md +0 -1
  73. package/docs/testing.md +72 -0
  74. package/docs/upgrading.md +281 -0
  75. package/package.json +18 -19
@@ -19,7 +19,7 @@
19
19
  next_page: "Next",
20
20
  page_x_of_y: "Page {page} of {pageCount}",
21
21
  no_data: "No data",
22
- select_all_rows: "Select all rows",
22
+ select_all_rows: "Select all rows on this page",
23
23
  select_row: "Select row",
24
24
  };
25
25
  let out = m[k] ?? fallback ?? k;
@@ -71,6 +71,8 @@
71
71
  selected?: Set<string | number>;
72
72
  /** Toggle row selection when clicking anywhere on the row */
73
73
  selectOnRowClick?: boolean;
74
+ /** Return true to disable selection for a specific row */
75
+ selectDisabledBy?: (row: T, index: number) => boolean;
74
76
 
75
77
  /** Callback when a row is clicked */
76
78
  onRowClick?: (row: T, index: number) => void;
@@ -78,9 +80,28 @@
78
80
  /** Show loading state (spinner overlay + reduced opacity) */
79
81
  loading?: boolean;
80
82
 
81
- /** Custom cell renderer snippet */
83
+ /** Custom cell renderer snippet (rendered in both desktop table and mobile card layouts; use `variant` to tell them apart) */
82
84
  cell?: Snippet<
83
- [{ column: DataTableColumn<T>; row: T; value: any; rowIndex: number }]
85
+ [
86
+ {
87
+ column: DataTableColumn<T>;
88
+ row: T;
89
+ value: any;
90
+ rowIndex: number;
91
+ variant: "desktop" | "mobile";
92
+ },
93
+ ]
94
+ >;
95
+ /** Custom desktop row renderer — replaces the entire `<tr>` */
96
+ row?: Snippet<
97
+ [
98
+ {
99
+ row: T;
100
+ columns: DataTableColumn<T>[];
101
+ rowIndex: number;
102
+ isSelected: boolean;
103
+ },
104
+ ]
84
105
  >;
85
106
  /** Batch actions bar snippet (shown when items are selected) */
86
107
  batchActions?: Snippet<
@@ -96,8 +117,6 @@
96
117
  empty?: Snippet;
97
118
  /** Custom mobile row card snippet */
98
119
  mobileRow?: Snippet<[{ row: T; columns: DataTableColumn<T>[]; rowIndex: number }]>;
99
- /** Default children snippet (not used directly) */
100
- children?: Snippet;
101
120
 
102
121
  /** Optional translate function */
103
122
  t?: TranslateFn;
@@ -130,13 +149,14 @@
130
149
  selectable = false,
131
150
  selected = $bindable(new Set()),
132
151
  selectOnRowClick = false,
152
+ selectDisabledBy,
133
153
  onRowClick,
134
154
  loading = false,
135
155
  cell,
156
+ row,
136
157
  batchActions,
137
158
  empty,
138
159
  mobileRow,
139
- children,
140
160
  t = t_default,
141
161
  small = false,
142
162
  unstyled = false,
@@ -154,15 +174,21 @@
154
174
 
155
175
  // --- Selection ---
156
176
  let allRowIds = $derived(data.map((row, i) => getRowId(row, i)));
177
+ let selectableRowIds = $derived.by(() => {
178
+ if (!selectDisabledBy) return allRowIds;
179
+ return data
180
+ .map((row, i) => (selectDisabledBy(row, i) ? null : getRowId(row, i)))
181
+ .filter((id): id is string | number => id !== null);
182
+ });
157
183
 
158
184
  let allOnPageSelected = $derived.by(() => {
159
- if (!selectable || allRowIds.length === 0) return false;
160
- return allRowIds.every((id) => selected.has(id));
185
+ if (!selectable || selectableRowIds.length === 0) return false;
186
+ return selectableRowIds.every((id) => selected.has(id));
161
187
  });
162
188
 
163
189
  let someOnPageSelected = $derived.by(() => {
164
- if (!selectable || allRowIds.length === 0) return false;
165
- return allRowIds.some((id) => selected.has(id)) && !allOnPageSelected;
190
+ if (!selectable || selectableRowIds.length === 0) return false;
191
+ return selectableRowIds.some((id) => selected.has(id)) && !allOnPageSelected;
166
192
  });
167
193
 
168
194
  let selectedRows = $derived.by(() => {
@@ -173,11 +199,11 @@
173
199
  function toggleSelectAll() {
174
200
  if (allOnPageSelected) {
175
201
  const next = new Set(selected);
176
- for (const id of allRowIds) next.delete(id);
202
+ for (const id of selectableRowIds) next.delete(id);
177
203
  selected = next;
178
204
  } else {
179
205
  const next = new Set(selected);
180
- for (const id of allRowIds) next.add(id);
206
+ for (const id of selectableRowIds) next.add(id);
181
207
  selected = next;
182
208
  }
183
209
  }
@@ -248,7 +274,7 @@
248
274
  <thead>
249
275
  <tr>
250
276
  {#if selectable}
251
- <th data-checkbox class="stuic-checkbox">
277
+ <th scope="col" data-checkbox class="stuic-checkbox">
252
278
  <input
253
279
  type="checkbox"
254
280
  checked={allOnPageSelected}
@@ -260,6 +286,7 @@
260
286
  {/if}
261
287
  {#each columns as col (col.key)}
262
288
  <th
289
+ scope="col"
263
290
  class={col.classHeader}
264
291
  data-align={!unstyled && col.align ? col.align : undefined}
265
292
  style={col.width ? `width: ${col.width}` : undefined}
@@ -274,46 +301,53 @@
274
301
  </tr>
275
302
  </thead>
276
303
  <tbody>
277
- {#each data as row, rowIndex (getRowId(row, rowIndex))}
278
- {@const rowId = getRowId(row, rowIndex)}
304
+ {#each data as rowData, rowIndex (getRowId(rowData, rowIndex))}
305
+ {@const rowId = getRowId(rowData, rowIndex)}
279
306
  {@const isSelected = selectable && selected.has(rowId)}
280
- <tr
281
- data-hoverable={!unstyled ? "true" : undefined}
282
- data-clickable={!unstyled && (onRowClick || selectOnRowClick)
283
- ? "true"
284
- : undefined}
285
- data-selected={!unstyled && isSelected ? "true" : undefined}
286
- onclick={(e) => handleRowClick(row, rowIndex, e)}
287
- >
288
- {#if selectable}
289
- <td data-checkbox class="stuic-checkbox">
290
- <input
291
- type="checkbox"
292
- checked={isSelected}
293
- onchange={() => toggleSelectRow(rowId)}
294
- aria-label={t("select_row")}
295
- />
296
- </td>
297
- {/if}
298
- {#each columns as col (col.key)}
299
- {@const value = getCellValue(row, col)}
300
- <td
301
- class={col.class}
302
- data-align={!unstyled && col.align ? col.align : undefined}
303
- >
304
- {#if cell}
305
- {@render cell({
306
- column: col,
307
- row,
308
- value,
309
- rowIndex,
310
- })}
311
- {:else}
312
- {getCellDisplay(row, col)}
313
- {/if}
314
- </td>
315
- {/each}
316
- </tr>
307
+ {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
308
+ {#if row}
309
+ {@render row({ row: rowData, columns, rowIndex, isSelected })}
310
+ {:else}
311
+ <tr
312
+ data-hoverable={!unstyled ? "true" : undefined}
313
+ data-clickable={!unstyled && (onRowClick || selectOnRowClick)
314
+ ? "true"
315
+ : undefined}
316
+ data-selected={!unstyled && isSelected ? "true" : undefined}
317
+ onclick={(e) => handleRowClick(rowData, rowIndex, e)}
318
+ >
319
+ {#if selectable}
320
+ <td data-checkbox class="stuic-checkbox">
321
+ <input
322
+ type="checkbox"
323
+ checked={isSelected}
324
+ disabled={selectDisabled}
325
+ onchange={() => toggleSelectRow(rowId)}
326
+ aria-label={t("select_row")}
327
+ />
328
+ </td>
329
+ {/if}
330
+ {#each columns as col (col.key)}
331
+ {@const value = getCellValue(rowData, col)}
332
+ <td
333
+ class={col.class}
334
+ data-align={!unstyled && col.align ? col.align : undefined}
335
+ >
336
+ {#if cell}
337
+ {@render cell({
338
+ column: col,
339
+ row: rowData,
340
+ value,
341
+ rowIndex,
342
+ variant: "desktop",
343
+ })}
344
+ {:else}
345
+ {getCellDisplay(rowData, col)}
346
+ {/if}
347
+ </td>
348
+ {/each}
349
+ </tr>
350
+ {/if}
317
351
  {:else}
318
352
  <tr>
319
353
  <td
@@ -337,12 +371,13 @@
337
371
  class={!unstyled ? "stuic-data-table-cards" : undefined}
338
372
  data-loading={!unstyled && loading ? "true" : undefined}
339
373
  >
340
- {#each data as row, rowIndex (getRowId(row, rowIndex))}
341
- {@const rowId = getRowId(row, rowIndex)}
374
+ {#each data as rowData, rowIndex (getRowId(rowData, rowIndex))}
375
+ {@const rowId = getRowId(rowData, rowIndex)}
342
376
  {@const isSelected = selectable && selected.has(rowId)}
377
+ {@const selectDisabled = !!selectDisabledBy?.(rowData, rowIndex)}
343
378
  {#if mobileRow}
344
379
  {@render mobileRow({
345
- row,
380
+ row: rowData,
346
381
  columns: mobileColumns,
347
382
  rowIndex,
348
383
  })}
@@ -357,7 +392,7 @@
357
392
  data-selected={!unstyled && isSelected ? "true" : undefined}
358
393
  role={onRowClick || selectOnRowClick ? "button" : undefined}
359
394
  tabindex={onRowClick || selectOnRowClick ? 0 : undefined}
360
- onclick={(e) => handleRowClick(row, rowIndex, e)}
395
+ onclick={(e) => handleRowClick(rowData, rowIndex, e)}
361
396
  onkeydown={(e) => {
362
397
  if (
363
398
  (onRowClick || selectOnRowClick) &&
@@ -367,22 +402,23 @@
367
402
  if (selectable && selectOnRowClick) {
368
403
  toggleSelectRow(rowId);
369
404
  }
370
- onRowClick?.(row, rowIndex);
405
+ onRowClick?.(rowData, rowIndex);
371
406
  }
372
407
  }}
373
408
  >
374
409
  {#if selectable}
375
- <div class="stuic-checkbox flex items-center gap-2 mb-1">
410
+ <div class={!unstyled ? "stuic-checkbox stuic-data-table-card-checkbox" : undefined}>
376
411
  <input
377
412
  type="checkbox"
378
413
  checked={isSelected}
414
+ disabled={selectDisabled}
379
415
  onchange={() => toggleSelectRow(rowId)}
380
416
  aria-label={t("select_row")}
381
417
  />
382
418
  </div>
383
419
  {/if}
384
420
  {#each mobileColumns as col (col.key)}
385
- {@const value = getCellValue(row, col)}
421
+ {@const value = getCellValue(rowData, col)}
386
422
  <div class={!unstyled ? "stuic-data-table-card-row" : undefined}>
387
423
  <span class={!unstyled ? "stuic-data-table-card-label" : undefined}>
388
424
  {#if isTHCNotEmpty(col.label)}
@@ -395,12 +431,13 @@
395
431
  {#if cell}
396
432
  {@render cell({
397
433
  column: col,
398
- row,
434
+ row: rowData,
399
435
  value,
400
436
  rowIndex,
437
+ variant: "mobile",
401
438
  })}
402
439
  {:else}
403
- {getCellDisplay(row, col)}
440
+ {getCellDisplay(rowData, col)}
404
441
  {/if}
405
442
  </span>
406
443
  </div>
@@ -412,7 +449,7 @@
412
449
  {#if empty}
413
450
  {@render empty()}
414
451
  {:else}
415
- No data
452
+ {t("no_data")}
416
453
  {/if}
417
454
  </div>
418
455
  {/each}
@@ -38,17 +38,29 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
38
38
  selected?: Set<string | number>;
39
39
  /** Toggle row selection when clicking anywhere on the row */
40
40
  selectOnRowClick?: boolean;
41
+ /** Return true to disable selection for a specific row */
42
+ selectDisabledBy?: (row: T, index: number) => boolean;
41
43
  /** Callback when a row is clicked */
42
44
  onRowClick?: (row: T, index: number) => void;
43
45
  /** Show loading state (spinner overlay + reduced opacity) */
44
46
  loading?: boolean;
45
- /** Custom cell renderer snippet */
47
+ /** Custom cell renderer snippet (rendered in both desktop table and mobile card layouts; use `variant` to tell them apart) */
46
48
  cell?: Snippet<[
47
49
  {
48
50
  column: DataTableColumn<T>;
49
51
  row: T;
50
52
  value: any;
51
53
  rowIndex: number;
54
+ variant: "desktop" | "mobile";
55
+ }
56
+ ]>;
57
+ /** Custom desktop row renderer — replaces the entire `<tr>` */
58
+ row?: Snippet<[
59
+ {
60
+ row: T;
61
+ columns: DataTableColumn<T>[];
62
+ rowIndex: number;
63
+ isSelected: boolean;
52
64
  }
53
65
  ]>;
54
66
  /** Batch actions bar snippet (shown when items are selected) */
@@ -67,8 +79,6 @@ export interface Props<T = Record<string, any>> extends Omit<HTMLAttributes<HTML
67
79
  columns: DataTableColumn<T>[];
68
80
  rowIndex: number;
69
81
  }]>;
70
- /** Default children snippet (not used directly) */
71
- children?: Snippet;
72
82
  /** Optional translate function */
73
83
  t?: TranslateFn;
74
84
  /** Force mobile/card layout regardless of breakpoint */
@@ -28,14 +28,21 @@ loading state, and mobile card layout.
28
28
 
29
29
  ### With Paging
30
30
 
31
+ DataTable integrates with [`@marianmeres/paging-store`](https://github.com/marianmeres/paging-store). Pass its computed result and receive offsets back via `onPageChange`:
32
+
31
33
  ```svelte
34
+ <script lang="ts">
35
+ import { createPagingStore } from "@marianmeres/paging-store";
36
+
37
+ const paging = createPagingStore({ pageSize: 20, totalItems: 100 });
38
+ $: pagingResult = $paging; // or via $derived in Svelte 5 rune mode
39
+ </script>
40
+
32
41
  <DataTable
33
42
  {columns}
34
43
  {data}
35
- page={1}
36
- pageSize={20}
37
- totalItems={100}
38
- onPageChange={(p) => fetchPage(p)}
44
+ paging={pagingResult}
45
+ onPageChange={(offset) => paging.setOffset(offset)}
39
46
  />
40
47
  ```
41
48
 
@@ -57,11 +64,15 @@ loading state, and mobile card layout.
57
64
 
58
65
  ### Custom Cell Rendering
59
66
 
67
+ The `cell` snippet is used for both desktop and mobile layouts. Use the `variant` param if rendering differs per layout.
68
+
60
69
  ```svelte
61
70
  <DataTable {columns} {data}>
62
- {#snippet cell({ column, row, value })}
71
+ {#snippet cell({ column, row, value, rowIndex, variant })}
63
72
  {#if column.key === "status"}
64
73
  <span class="badge">{value}</span>
74
+ {:else if variant === "mobile"}
75
+ <em>{value}</em>
65
76
  {:else}
66
77
  {value}
67
78
  {/if}
@@ -69,6 +80,34 @@ loading state, and mobile card layout.
69
80
  </DataTable>
70
81
  ```
71
82
 
83
+ ### Custom Desktop Row
84
+
85
+ Replace the entire `<tr>` on desktop. When this snippet is provided, DataTable does not render the default row markup (checkbox, cells) — you own it all. Use for custom expandable rows, row grouping, etc.
86
+
87
+ ```svelte
88
+ <DataTable {columns} {data}>
89
+ {#snippet row({ row, columns, rowIndex, isSelected })}
90
+ <tr data-custom-row>
91
+ {#each columns as col}
92
+ <td>{row[col.key]}</td>
93
+ {/each}
94
+ </tr>
95
+ {/snippet}
96
+ </DataTable>
97
+ ```
98
+
99
+ ### Disabling Selection Per Row
100
+
101
+ ```svelte
102
+ <DataTable
103
+ {columns}
104
+ {data}
105
+ selectable
106
+ bind:selected
107
+ selectDisabledBy={(row) => row.locked === true}
108
+ />
109
+ ```
110
+
72
111
  ### Custom Mobile Layout
73
112
 
74
113
  ```svelte
@@ -84,26 +123,41 @@ loading state, and mobile card layout.
84
123
 
85
124
  ## Props
86
125
 
87
- | Prop | Type | Default | Description |
88
- | -------------- | ---------------------------------- | ------------- | --------------------------- |
89
- | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
90
- | `data` | `T[]` | required | Array of row data |
91
- | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
92
- | `page` | `number` | - | Current page (1-based) |
93
- | `pageSize` | `number` | - | Rows per page |
94
- | `totalItems` | `number` | - | Total items count |
95
- | `onPageChange` | `(page: number) => void` | - | Page change callback |
96
- | `selectable` | `boolean` | `false` | Enable selection checkboxes |
97
- | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
98
- | `onRowClick` | `(row, index) => void` | - | Row click callback |
99
- | `loading` | `boolean` | `false` | Show loading overlay |
100
- | `cell` | `Snippet` | - | Custom cell renderer |
101
- | `batchActions` | `Snippet` | - | Batch action bar content |
102
- | `empty` | `Snippet` | - | Custom empty state |
103
- | `mobileRow` | `Snippet` | - | Custom mobile row card |
104
- | `unstyled` | `boolean` | `false` | Skip default styling |
105
- | `class` | `string` | - | Additional CSS classes |
106
- | `el` | `HTMLDivElement` | - | Bindable element ref |
126
+ | Prop | Type | Default | Description |
127
+ | ------------------- | ------------------------------------------ | ------------- | ----------------------------------------------------------------- |
128
+ | `columns` | `DataTableColumn<T>[]` | required | Column definitions |
129
+ | `data` | `T[]` | required | Array of row data |
130
+ | `getRowId` | `(row, index) => string \| number` | `(_, i) => i` | Row ID extractor |
131
+ | `paging` | `PagingCalcResult` | - | Paging state (from `@marianmeres/paging-store`) |
132
+ | `onPageChange` | `(offset: number) => void` | - | Called with the new offset when the user navigates pages |
133
+ | `selectable` | `boolean` | `false` | Enable selection checkboxes |
134
+ | `selected` | `Set<string \| number>` | `new Set()` | Selected row IDs (bindable) |
135
+ | `selectOnRowClick` | `boolean` | `false` | Clicking anywhere on a row toggles its selection |
136
+ | `selectDisabledBy` | `(row, index) => boolean` | - | Return `true` to disable selection for a specific row |
137
+ | `onRowClick` | `(row, index) => void` | - | Row click callback |
138
+ | `loading` | `boolean` | `false` | Show loading overlay |
139
+ | `small` | `boolean` | `false` | Force mobile/card layout regardless of viewport |
140
+ | `t` | `TranslateFn` | built-in | Optional translation function |
141
+ | `cell` | `Snippet` | - | Custom cell renderer (desktop + mobile) |
142
+ | `row` | `Snippet` | - | Custom desktop `<tr>` renderer (overrides default row) |
143
+ | `mobileRow` | `Snippet` | - | Custom mobile card renderer |
144
+ | `batchActions` | `Snippet` | - | Batch action bar content |
145
+ | `empty` | `Snippet` | - | Custom empty state |
146
+ | `unstyled` | `boolean` | `false` | Skip default styling |
147
+ | `class` | `string` | - | Additional CSS classes |
148
+ | `el` | `HTMLDivElement` | - | Bindable element ref |
149
+
150
+ ### Snippet signatures
151
+
152
+ | Snippet | Props |
153
+ | -------------- | ------------------------------------------------------------------------------------------------ |
154
+ | `cell` | `{ column, row, value, rowIndex, variant: "desktop" \| "mobile" }` |
155
+ | `row` | `{ row, columns, rowIndex, isSelected }` — desktop only |
156
+ | `mobileRow` | `{ row, columns, rowIndex }` — mobile only |
157
+ | `batchActions` | `{ selected, selectedRows, clearSelection }` |
158
+ | `empty` | — |
159
+
160
+ > **Note:** "Select all rows" affects only the rows currently in `data` (i.e. the current page when using external paging). Rows for which `selectDisabledBy` returns `true` are excluded from "select all".
107
161
 
108
162
  ## DataTableColumn
109
163
 
@@ -278,6 +278,13 @@
278
278
  background: var(--stuic-data-table-card-bg-selected);
279
279
  }
280
280
 
281
+ .stuic-data-table-card-checkbox {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 0.5rem;
285
+ margin-bottom: 0.25rem;
286
+ }
287
+
281
288
  .stuic-data-table-card-row {
282
289
  display: flex;
283
290
  justify-content: space-between;
@@ -201,6 +201,10 @@
201
201
  triggerEl?: HTMLButtonElement;
202
202
  /** Reference to dropdown element */
203
203
  dropdownEl?: HTMLDivElement;
204
+ /** Reference to the wrapper element */
205
+ el?: HTMLDivElement;
206
+ /** Opt out of stuic base classes for full styling control */
207
+ unstyled?: boolean;
204
208
  /** Optional, used only when css positioning not supported (iPhone)*/
205
209
  noScrollLock?: boolean;
206
210
  }
@@ -298,6 +302,8 @@
298
302
  onSelect,
299
303
  triggerEl = $bindable(),
300
304
  dropdownEl = $bindable(),
305
+ el = $bindable(),
306
+ unstyled = false,
301
307
  scrollbarGutter,
302
308
  noScrollLock,
303
309
  ...rest
@@ -309,7 +315,6 @@
309
315
  const anchorName = `--dropdown-anchor-${triggerId}`;
310
316
 
311
317
  // State
312
- let wrapperEl: HTMLDivElement = $state()!;
313
318
  let activeItemEl: HTMLButtonElement | undefined = $state();
314
319
  const reducedMotion = prefersReducedMotion();
315
320
 
@@ -615,7 +620,7 @@
615
620
 
616
621
  // Click outside handler — only active when open (prevents stale refs on destroy)
617
622
  const _clickOutside = onClickOutside(
618
- () => wrapperEl,
623
+ () => el,
619
624
  () => {
620
625
  if (closeOnClickOutside && isOpen) {
621
626
  isOpen = false;
@@ -626,7 +631,7 @@
626
631
  );
627
632
 
628
633
  $effect(() => {
629
- if (isOpen && wrapperEl) _clickOutside.start();
634
+ if (isOpen && el) _clickOutside.start();
630
635
  else _clickOutside.stop();
631
636
  });
632
637
 
@@ -808,8 +813,8 @@
808
813
  />
809
814
 
810
815
  <div
811
- bind:this={wrapperEl}
812
- class={twMerge(DROPDOWN_MENU_BASE_CLASSES, classProp)}
816
+ bind:this={el}
817
+ class={unstyled ? classProp : twMerge(DROPDOWN_MENU_BASE_CLASSES, classProp)}
813
818
  style:anchor-name={isSupported ? anchorName : undefined}
814
819
  >
815
820
  <!-- Trigger -->
@@ -830,7 +835,7 @@
830
835
  <button
831
836
  bind:this={triggerEl}
832
837
  id={triggerId}
833
- class={twMerge(DROPDOWN_MENU_TRIGGER_CLASSES, classTrigger)}
838
+ class={unstyled ? classTrigger : twMerge(DROPDOWN_MENU_TRIGGER_CLASSES, classTrigger)}
834
839
  onclick={() => (isOpen = !isOpen)}
835
840
  aria-haspopup="menu"
836
841
  aria-expanded={isOpen}
@@ -851,15 +856,15 @@
851
856
 
852
857
  <!-- Backdrop (fallback mode only) -->
853
858
  {#if isOpen && !isSupported && showBackdrop}
859
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
854
860
  <div
855
- class={twMerge(DROPDOWN_MENU_BACKDROP_CLASSES, classBackdrop)}
861
+ class={unstyled ? classBackdrop : twMerge(DROPDOWN_MENU_BACKDROP_CLASSES, classBackdrop)}
856
862
  onclick={() => {
857
863
  if (closeOnClickOutside) {
858
864
  isOpen = false;
859
865
  triggerEl?.focus();
860
866
  }
861
867
  }}
862
- onkeydown={() => {}}
863
868
  role="presentation"
864
869
  transition:fade={{ duration: transitionDuration }}
865
870
  ></div>
@@ -872,11 +877,13 @@
872
877
  id={dropdownId}
873
878
  role="menu"
874
879
  aria-labelledby={triggerId}
875
- class={twMerge(
876
- DROPDOWN_MENU_DROPDOWN_CLASSES,
877
- !isSupported && "w-4/5 max-w-32",
878
- classDropdown
879
- )}
880
+ class={unstyled
881
+ ? twMerge(!isSupported && "w-4/5 max-w-sm", classDropdown)
882
+ : twMerge(
883
+ DROPDOWN_MENU_DROPDOWN_CLASSES,
884
+ !isSupported && "w-4/5 max-w-sm",
885
+ classDropdown
886
+ )}
880
887
  style={dropdownStyle}
881
888
  transition:slide={{ duration: transitionDuration }}
882
889
  >
@@ -886,7 +893,9 @@
886
893
  <button
887
894
  type="button"
888
895
  aria-label="Close"
889
- class="stuic-close-button absolute right-0 top-0 pointer-events-auto"
896
+ class={unstyled
897
+ ? "absolute right-0 top-0 pointer-events-auto"
898
+ : "stuic-close-button absolute right-0 top-0 pointer-events-auto"}
890
899
  onclick={() => {
891
900
  isOpen = false;
892
901
  triggerEl?.focus();
@@ -957,12 +966,16 @@
957
966
  {:else if item.type === "divider"}
958
967
  <div
959
968
  role="separator"
960
- class={twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
969
+ class={unstyled
970
+ ? twMerge(classDivider, item.class)
971
+ : twMerge(DROPDOWN_MENU_DIVIDER_CLASSES, classDivider, item.class)}
961
972
  ></div>
962
973
  {:else if item.type === "header"}
963
974
  <div
964
975
  role="presentation"
965
- class={twMerge(DROPDOWN_MENU_HEADER_CLASSES, classHeader, item.class)}
976
+ class={unstyled
977
+ ? twMerge(classHeader, item.class)
978
+ : twMerge(DROPDOWN_MENU_HEADER_CLASSES, classHeader, item.class)}
966
979
  >
967
980
  <Thc thc={item.label} />
968
981
  </div>
@@ -1043,20 +1056,24 @@
1043
1056
  {#if childItem.type === "divider"}
1044
1057
  <div
1045
1058
  role="separator"
1046
- class={twMerge(
1047
- DROPDOWN_MENU_DIVIDER_CLASSES,
1048
- classDivider,
1049
- childItem.class
1050
- )}
1059
+ class={unstyled
1060
+ ? twMerge(classDivider, childItem.class)
1061
+ : twMerge(
1062
+ DROPDOWN_MENU_DIVIDER_CLASSES,
1063
+ classDivider,
1064
+ childItem.class
1065
+ )}
1051
1066
  ></div>
1052
1067
  {:else if childItem.type === "header"}
1053
1068
  <div
1054
1069
  role="presentation"
1055
- class={twMerge(
1056
- DROPDOWN_MENU_HEADER_CLASSES,
1057
- classHeader,
1058
- childItem.class
1059
- )}
1070
+ class={unstyled
1071
+ ? twMerge(classHeader, childItem.class)
1072
+ : twMerge(
1073
+ DROPDOWN_MENU_HEADER_CLASSES,
1074
+ classHeader,
1075
+ childItem.class
1076
+ )}
1060
1077
  >
1061
1078
  <Thc thc={childItem.label} />
1062
1079
  </div>