@joewinke/jatui 0.26.0 → 0.27.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 (73) hide show
  1. package/CLAUDE.md +106 -1
  2. package/dist/actions/keyboardScope.d.ts +58 -0
  3. package/dist/actions/keyboardScope.js +110 -0
  4. package/dist/actions/listNav.d.ts +48 -3
  5. package/dist/actions/listNav.js +267 -77
  6. package/dist/actions/longpress.d.ts +37 -0
  7. package/dist/actions/longpress.js +105 -0
  8. package/dist/actions/stickToBottom.d.ts +66 -0
  9. package/dist/actions/stickToBottom.js +155 -0
  10. package/dist/actions/swipe.d.ts +64 -0
  11. package/dist/actions/swipe.js +179 -0
  12. package/dist/components/BottomSheet.svelte +280 -0
  13. package/dist/components/BottomSheet.svelte.d.ts +27 -0
  14. package/dist/components/ConfirmDialog.svelte +21 -0
  15. package/dist/components/ConfirmDialog.svelte.d.ts +11 -1
  16. package/dist/components/ConfirmModal.svelte +15 -0
  17. package/dist/components/ConfirmModal.svelte.d.ts +5 -0
  18. package/dist/components/Drawer.svelte +18 -1
  19. package/dist/components/Drawer.svelte.d.ts +9 -1
  20. package/dist/components/InputDialog.svelte +21 -0
  21. package/dist/components/InputDialog.svelte.d.ts +11 -1
  22. package/dist/components/Modal.svelte +18 -1
  23. package/dist/components/Modal.svelte.d.ts +8 -0
  24. package/dist/components/basic/Empty.svelte +173 -9
  25. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  26. package/dist/components/basic/ErrorState.svelte +128 -0
  27. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  28. package/dist/components/basic/JumpToLatest.svelte +60 -0
  29. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  30. package/dist/components/basic/PageHeader.svelte +216 -0
  31. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  32. package/dist/components/basic/ProgressButton.svelte +221 -0
  33. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  34. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  35. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  36. package/dist/components/graph/GraphCanvas.svelte +212 -27
  37. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  38. package/dist/components/graph/api.d.ts +52 -0
  39. package/dist/components/graph/api.js +66 -0
  40. package/dist/components/graph/index.d.ts +2 -0
  41. package/dist/components/graph/index.js +1 -0
  42. package/dist/components/graph/theme.js +13 -0
  43. package/dist/components/messaging/MessageItem.svelte +5 -0
  44. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  45. package/dist/components/table/DataTable.svelte +452 -78
  46. package/dist/components/table/DataTable.svelte.d.ts +2 -2
  47. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  48. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  49. package/dist/components/table/columnStorage.d.ts +29 -0
  50. package/dist/components/table/columnStorage.js +61 -0
  51. package/dist/components/table/groupRows.d.ts +72 -0
  52. package/dist/components/table/groupRows.js +118 -0
  53. package/dist/components/table/index.d.ts +7 -1
  54. package/dist/components/table/index.js +3 -0
  55. package/dist/components/table/types.d.ts +121 -20
  56. package/dist/index.d.ts +25 -2
  57. package/dist/index.js +32 -1
  58. package/dist/utils/ansiToHtml.d.ts +83 -0
  59. package/dist/utils/ansiToHtml.js +543 -0
  60. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  61. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  62. package/dist/utils/pollBackoff.d.ts +83 -0
  63. package/dist/utils/pollBackoff.js +151 -0
  64. package/dist/utils/snapFlick.d.ts +59 -0
  65. package/dist/utils/snapFlick.js +66 -0
  66. package/dist/video/advisors/qr.d.ts +1 -1
  67. package/dist/virtual/createRowWindow.d.ts +75 -0
  68. package/dist/virtual/createRowWindow.js +129 -0
  69. package/dist/virtual/index.d.ts +4 -0
  70. package/dist/virtual/index.js +5 -0
  71. package/dist/virtual/rowWindowCore.d.ts +202 -0
  72. package/dist/virtual/rowWindowCore.js +510 -0
  73. package/package.json +11 -1
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" generics="TData">
2
- import { tick } from 'svelte';
2
+ import { tick, untrack } from 'svelte';
3
3
  import {
4
4
  createTable,
5
5
  getCoreRowModel,
@@ -13,7 +13,15 @@
13
13
  type Row,
14
14
  } from '@tanstack/table-core';
15
15
  import { flexRender } from './flexRender.js';
16
- import type { DataTableProps, DataTableRowProps, DataTableSortEvent, GroupedItem } from './types.js';
16
+ import { groupRows, groupPathKey, type GroupedGroupItem, type GroupedRowItem, type GroupLevel } from './groupRows.js';
17
+ import type {
18
+ DataTableProps,
19
+ DataTableRowProps,
20
+ DataTableSortEvent,
21
+ DataTableRenderItem,
22
+ DataTableRowWindow,
23
+ GroupedItem,
24
+ } from './types.js';
17
25
 
18
26
  let {
19
27
  data,
@@ -40,6 +48,18 @@
40
48
  variant = 'zebra',
41
49
  tableClass = '',
42
50
  api = $bindable(),
51
+ groupBy,
52
+ collapsed = $bindable(),
53
+ onCollapsedChange,
54
+ groupLabel,
55
+ groupHeader,
56
+ stickyGroupHeaders = false,
57
+ stickyHeader = false,
58
+ maxHeight,
59
+ virtual,
60
+ pinnedRowIds,
61
+ ariaGrid = false,
62
+ ariaLabel,
43
63
  }: DataTableProps<TData> = $props();
44
64
 
45
65
  // ── State ──────────────────────────────────────────────────────────────
@@ -47,6 +67,9 @@
47
67
  // state seeded from `initialSorting`.
48
68
  // svelte-ignore state_referenced_locally
49
69
  if (sorting === undefined) sorting = initialSorting ?? [];
70
+ // Same contract for the collapsed-group set.
71
+ // svelte-ignore state_referenced_locally
72
+ if (collapsed === undefined) collapsed = new Set();
50
73
  const sortState = $derived<SortingState>(sorting ?? []);
51
74
  let rowSelection = $state<RowSelectionState>({});
52
75
  // svelte-ignore state_referenced_locally
@@ -83,8 +106,19 @@
83
106
  const allColumns = $derived<ColumnDef<TData, unknown>[]>(selectable ? [checkboxCol, ...columns] : columns);
84
107
 
85
108
  // ── Table instance ─────────────────────────────────────────────────────
86
- const table = $derived.by(() => {
87
- const opts: TableOptionsResolved<TData> = {
109
+ // Created ONCE; every reactive change is pushed in with `setOptions` (R6).
110
+ // It used to be rebuilt in a `$derived.by` on every sort / selection / data
111
+ // change, which threw away TanStack's memoised row models and handed the
112
+ // template brand-new Row objects, so every row re-rendered on every poll.
113
+ //
114
+ // Row-model factories are created once too: TanStack caches the model a
115
+ // factory builds on first use, so a fresh factory per update would be ignored.
116
+ const coreRowModel = getCoreRowModel<TData>();
117
+ const sortedRowModel = getSortedRowModel<TData>();
118
+ const paginationRowModel = getPaginationRowModel<TData>();
119
+
120
+ function buildOptions(): TableOptionsResolved<TData> {
121
+ return {
88
122
  data,
89
123
  columns: allColumns,
90
124
  state: {
@@ -110,28 +144,55 @@
110
144
  }
111
145
  : () => {},
112
146
  onStateChange: () => {},
113
- getCoreRowModel: getCoreRowModel(),
114
- getSortedRowModel: sortable && !manualSorting ? getSortedRowModel() : undefined,
115
- getPaginationRowModel: paginated ? getPaginationRowModel() : undefined,
147
+ getCoreRowModel: coreRowModel,
148
+ getSortedRowModel: sortable && !manualSorting ? sortedRowModel : undefined,
149
+ getPaginationRowModel: paginated ? paginationRowModel : undefined,
116
150
  renderFallbackValue: '',
117
151
  };
118
- const instance = createTable(opts);
119
- // createTable takes a fully-resolved state — unlike the framework adapters it does
120
- // not fill in defaults, so feature state we never touch (columnPinning, columnVisibility,
121
- // …) lands undefined and getHeaderGroups() dies reading columnPinning.left. Merge the
122
- // instance defaults under our reactive slices, the same way the TanStack adapters do.
123
- instance.setOptions((prev) => ({
152
+ }
153
+
154
+ const table = createTable(untrack(buildOptions));
155
+ /** The row-model mode the cached `_get*RowModel`s were built for. */
156
+ let modelMode = '';
157
+
158
+ /**
159
+ * Pushes the current props/state into the one instance. Its value is a fresh
160
+ * token per change: everything that reads the table goes through `t()` (or
161
+ * reads this), so it re-runs exactly when the table's inputs moved.
162
+ */
163
+ const tableRev = $derived.by(() => {
164
+ const opts = buildOptions();
165
+ const mode = `${sortable && !manualSorting}|${paginated}`;
166
+ if (mode !== modelMode) {
167
+ // TanStack caches the sorted / paginated model on first use and never
168
+ // re-reads the option, so a flag flip must drop the cache.
169
+ table._getSortedRowModel = undefined;
170
+ table._getPaginationRowModel = undefined;
171
+ modelMode = mode;
172
+ }
173
+ // createTable takes a fully-resolved state — unlike the framework adapters it
174
+ // does not fill in defaults, so feature state we never touch (columnPinning,
175
+ // columnVisibility, …) would land undefined and getHeaderGroups() dies reading
176
+ // columnPinning.left. Merge the instance defaults under our reactive slices.
177
+ table.setOptions((prev) => ({
124
178
  ...prev,
125
- state: { ...instance.initialState, ...opts.state },
179
+ ...opts,
180
+ state: { ...table.initialState, ...opts.state },
126
181
  }));
127
- return instance;
182
+ return {};
128
183
  });
129
184
 
130
- const headerGroups = $derived(table.getHeaderGroups());
131
- const rows = $derived(table.getRowModel().rows);
132
- const pageCount = $derived(table.getPageCount());
133
- const canPrevPage = $derived(table.getCanPreviousPage());
134
- const canNextPage = $derived(table.getCanNextPage());
185
+ /** The table, read through `tableRev` so the caller re-runs when it changes. */
186
+ function t() {
187
+ void tableRev;
188
+ return table;
189
+ }
190
+
191
+ const headerGroups = $derived(t().getHeaderGroups());
192
+ const rows = $derived(t().getRowModel().rows);
193
+ const pageCount = $derived(t().getPageCount());
194
+ const canPrevPage = $derived(t().getCanPreviousPage());
195
+ const canNextPage = $derived(t().getCanNextPage());
135
196
 
136
197
  const tableClasses = $derived(
137
198
  [variant === 'unstyled' ? '' : variant === 'plain' ? 'table' : 'table table-zebra', 'w-full', tableClass]
@@ -144,16 +205,168 @@
144
205
 
145
206
  $effect(() => {
146
207
  if (!selectable || !onSelectionChange) return;
147
- const selected = table.getSelectedRowModel().rows.map((r) => r.original);
208
+ const selected = t().getSelectedRowModel().rows.map((r) => r.original);
148
209
  onSelectionChange(selected);
149
210
  });
150
211
 
212
+ // ── Render list ────────────────────────────────────────────────────────
213
+ // Everything the body draws, in order: group headers, data rows and the
214
+ // detail row under each expanded row. One entry = one `<tr>`, which is what
215
+ // the row window counts and measures.
216
+ const GROUP_KEY = '\u0000group:';
217
+ const DETAIL_KEY = '\u0000detail:';
218
+
219
+ type Entry =
220
+ | { kind: 'group'; key: string; item: GroupedGroupItem<TData>; pathKey: string }
221
+ | { kind: 'row'; key: string; item: GroupedRowItem<TData>; row: Row<TData> }
222
+ | { kind: 'detail'; key: string; row: Row<TData> };
223
+
224
+ const grouped = $derived(!!groupBy && groupBy.length > 0);
225
+
226
+ const entries = $derived.by<Entry[]>(() => {
227
+ const list = rows;
228
+ const out: Entry[] = [];
229
+ const pushRow = (row: Row<TData>, item: GroupedRowItem<TData>) => {
230
+ out.push({ kind: 'row', key: row.id, item, row });
231
+ if (rowIsExpanded(row)) out.push({ kind: 'detail', key: DETAIL_KEY + row.id, row });
232
+ };
233
+
234
+ if (!grouped) {
235
+ const n = list.length;
236
+ list.forEach((row, i) => {
237
+ const position = n === 1 ? 'only' : i === 0 ? 'first' : i === n - 1 ? 'last' : 'middle';
238
+ pushRow(row, { kind: 'row', row: row.original, id: row.id, depth: 0, groupPath: [], position });
239
+ });
240
+ return out;
241
+ }
242
+
243
+ // Group the TanStack rows themselves (not their originals) so duplicate
244
+ // originals stay distinct, then hand callers originals in the items.
245
+ const levels: GroupLevel<Row<TData>>[] = groupBy!.map((level) => ({
246
+ key: (r) => level.key(r.original),
247
+ compare: level.compare,
248
+ showHeader: level.showHeader
249
+ ? (k, rs) => level.showHeader!(k, rs.map((r) => r.original))
250
+ : undefined,
251
+ }));
252
+ const items = groupRows(list, levels, {
253
+ getRowId: (r) => r.id,
254
+ collapsed: collapsed ?? new Set(),
255
+ label: groupLabel,
256
+ });
257
+ for (const it of items) {
258
+ if (it.kind === 'group') {
259
+ const pathKey = groupPathKey(it.path);
260
+ out.push({
261
+ kind: 'group',
262
+ key: GROUP_KEY + pathKey,
263
+ pathKey,
264
+ item: { ...it, rows: it.rows.map((r) => r.original) },
265
+ });
266
+ } else {
267
+ pushRow(it.row, { ...it, row: it.row.original });
268
+ }
269
+ }
270
+ return out;
271
+ });
272
+
273
+ /** Group-header indices per depth, outermost first — the window's sticky levels. */
274
+ const stickyLevels = $derived.by<number[][]>(() => {
275
+ const levels: number[][] = [];
276
+ entries.forEach((e, i) => {
277
+ if (e.kind !== 'group') return;
278
+ (levels[e.item.depth] ??= []).push(i);
279
+ });
280
+ for (let d = 0; d < levels.length; d++) levels[d] ??= [];
281
+ return levels;
282
+ });
283
+
284
+ // ── Windowing (grid mode) ──────────────────────────────────────────────
285
+ function renderItem(e: Entry): DataTableRenderItem<TData> {
286
+ if (e.kind === 'detail') return { kind: 'detail', row: e.row.original, id: e.row.id };
287
+ return e.item as GroupedItem<TData>;
288
+ }
289
+
290
+ function estimate(e: Entry | undefined): number {
291
+ if (!e) return 40;
292
+ const est = virtual?.estimateRowSize;
293
+ if (typeof est === 'function') return est(renderItem(e));
294
+ if (e.kind === 'group') return 36;
295
+ if (e.kind === 'detail') return 120;
296
+ return typeof est === 'number' ? est : 40;
297
+ }
298
+
299
+ // Read once: the window must be created during component init.
300
+ const virtualAtMount = untrack(() => virtual);
301
+ const win: DataTableRowWindow | null = virtualAtMount
302
+ ? virtualAtMount.createRowWindow({
303
+ count: () => entries.length,
304
+ getKey: (i) => entries[i]?.key ?? String(i),
305
+ estimateSize: (i) => estimate(entries[i]),
306
+ overscan: virtualAtMount.overscan,
307
+ get threshold() {
308
+ return virtual?.threshold ?? 0;
309
+ },
310
+ scrollRoot: virtualAtMount.scrollRoot ?? 'auto',
311
+ scrollPaddingStart: virtualAtMount.scrollPaddingStart,
312
+ pinned: () => {
313
+ const keys: string[] = [];
314
+ for (const id of pinnedRowIds ?? []) keys.push(id, DETAIL_KEY + id);
315
+ return keys;
316
+ },
317
+ stickyIndices: () => (stickyGroupHeaders ? stickyLevels : []),
318
+ })
319
+ : null;
320
+
321
+ const noopAction = (_node: HTMLElement, _key?: string) => ({ update() {}, destroy() {} });
322
+ const measureAction = win ? win.measure : noopAction;
323
+ const anchorAction = win ? win.anchor : noopAction;
324
+
325
+ type Slot = { entry: Entry; index: number; spacers: number[] };
326
+
327
+ /**
328
+ * Spacer rows that stand in for `skipped` undrawn entries totalling `height`
329
+ * px. The COUNT keeps `nth-child` parity with the unwindowed table, so zebra
330
+ * striping does not flip as the slice scrolls: an odd gap gets one spacer, an
331
+ * even one gets two.
332
+ */
333
+ function spacersFor(skipped: number, height: number): number[] {
334
+ if (skipped <= 0) return height > 0 ? [height, 0] : [];
335
+ return skipped % 2 === 1 ? [height] : [height, 0];
336
+ }
337
+
338
+ const slots = $derived.by<Slot[]>(() => {
339
+ if (!win) return entries.map((entry, index) => ({ entry, index, spacers: [] }));
340
+ const out: Slot[] = [];
341
+ let prev = -1;
342
+ for (const it of win.items) {
343
+ const entry = entries[it.index];
344
+ // The window can trail the entry list by one flush; skip strays.
345
+ if (!entry || entry.key !== it.key) continue;
346
+ const height = prev < 0 ? win.paddingTop : it.gapBefore;
347
+ out.push({ entry, index: it.index, spacers: spacersFor(it.index - prev - 1, height) });
348
+ prev = it.index;
349
+ }
350
+ return out;
351
+ });
352
+
353
+ // ── ARIA ───────────────────────────────────────────────────────────────
354
+ const ariaRows = $derived(ariaGrid || !!win);
355
+ const tableRole = $derived(ariaGrid ? (grouped ? 'treegrid' : 'grid') : undefined);
356
+ /** 1-based row index, counting the header rows. */
357
+ function ariaRowIndex(index: number): number | undefined {
358
+ return ariaRows ? index + 1 + headerGroups.length : undefined;
359
+ }
360
+
151
361
  // ── Imperative api ─────────────────────────────────────────────────────
152
362
  api = {
153
- orderedRowIds: () => table.getPrePaginationRowModel().rows.map((r) => r.id),
363
+ orderedRowIds: () =>
364
+ grouped && !paginated
365
+ ? entries.filter((e) => e.kind === 'row').map((e) => e.key)
366
+ : t().getPrePaginationRowModel().rows.map((r) => r.id),
154
367
  scrollToRow: async (id, align = 'auto') => {
155
368
  if (paginated) {
156
- const index = table.getPrePaginationRowModel().rows.findIndex((r) => r.id === id);
369
+ const index = t().getPrePaginationRowModel().rows.findIndex((r) => r.id === id);
157
370
  if (index < 0) return;
158
371
  const pageIndex = Math.floor(index / pagination.pageSize);
159
372
  if (pageIndex !== pagination.pageIndex) {
@@ -161,6 +374,10 @@
161
374
  await tick();
162
375
  }
163
376
  }
377
+ if (win) {
378
+ win.scrollToKey(id, { align });
379
+ return;
380
+ }
164
381
  const el = rootEl?.querySelector<HTMLElement>(`tr[data-row-id="${CSS.escape(id)}"]`);
165
382
  el?.scrollIntoView({ block: align === 'auto' ? 'nearest' : align });
166
383
  },
@@ -197,9 +414,9 @@
197
414
  }
198
415
 
199
416
  // ── Row helpers ────────────────────────────────────────────────────────
200
- function rowItem(row: Row<TData>, index: number, count: number): GroupedItem<TData> {
201
- const position = count === 1 ? 'only' : index === 0 ? 'first' : index === count - 1 ? 'last' : 'middle';
202
- return { kind: 'row', row: row.original, id: row.id, depth: 0, groupPath: [], position };
417
+ function isSelected(row: Row<TData>): boolean {
418
+ void tableRev;
419
+ return row.getIsSelected();
203
420
  }
204
421
 
205
422
  function rowIsExpanded(row: Row<TData>): boolean {
@@ -214,6 +431,14 @@
214
431
  expandedIds = next;
215
432
  }
216
433
 
434
+ function toggleGroup(pathKey: string) {
435
+ const next = new Set(collapsed ?? []);
436
+ if (next.has(pathKey)) next.delete(pathKey);
437
+ else next.add(pathKey);
438
+ collapsed = next;
439
+ onCollapsedChange?.(next);
440
+ }
441
+
217
442
  /** Clicks on these inside a row belong to the control, not to the row. */
218
443
  const INTERACTIVE = 'input, button, a, select, textarea, label, [contenteditable]:not([contenteditable="false"])';
219
444
 
@@ -249,6 +474,15 @@
249
474
  }
250
475
 
251
476
  const hasRowHandlers = $derived(!!rowProps || !!onRowClick || internalExpand);
477
+
478
+ // ── Scroll container ───────────────────────────────────────────────────
479
+ // The default wrapper scrolls horizontally, which also makes it the sticky
480
+ // containing block — a sticky header would stick to a box that never scrolls
481
+ // vertically. With sticky on and no `maxHeight`, clip instead (no scroll
482
+ // container); with `maxHeight`, the wrapper becomes the real scroller.
483
+ const sticky = $derived(stickyHeader || stickyGroupHeaders);
484
+ const scrollerClass = $derived(maxHeight ? 'dt-scroller' : sticky ? 'dt-clip' : 'overflow-x-auto');
485
+ let theadHeight = $state(0);
252
486
  </script>
253
487
 
254
488
  <div class="datatable-root {className}" bind:this={rootEl}>
@@ -276,19 +510,30 @@
276
510
  </table>
277
511
  </div>
278
512
  {:else}
279
- <div class="overflow-x-auto">
280
- <table class={tableClasses}>
281
- <thead>
282
- {#each headerGroups as headerGroup}
283
- <tr>
513
+ <div
514
+ class={scrollerClass}
515
+ style:max-height={maxHeight}
516
+ style:--dt-thead-h={sticky ? `${stickyHeader ? theadHeight : 0}px` : undefined}
517
+ >
518
+ <table
519
+ class={tableClasses}
520
+ class:dt-sticky-head={stickyHeader}
521
+ role={tableRole}
522
+ aria-label={ariaLabel}
523
+ aria-multiselectable={ariaGrid && selectable ? 'true' : undefined}
524
+ aria-rowcount={ariaRows ? entries.length + headerGroups.length : undefined}
525
+ >
526
+ <thead bind:offsetHeight={theadHeight}>
527
+ {#each headerGroups as headerGroup, hi}
528
+ <tr aria-rowindex={ariaRows ? hi + 1 : undefined}>
284
529
  {#each headerGroup.headers as header}
285
530
  {#if header.column.id === '__select'}
286
531
  <th class="dt-select-cell">
287
532
  <input
288
533
  type="checkbox"
289
534
  class="checkbox checkbox-sm"
290
- checked={table.getIsAllRowsSelected()}
291
- indeterminate={table.getIsSomeRowsSelected()}
535
+ checked={t().getIsAllRowsSelected()}
536
+ indeterminate={t().getIsSomeRowsSelected()}
292
537
  aria-label="Select all rows"
293
538
  onchange={() => table.toggleAllRowsSelected()}
294
539
  />
@@ -336,7 +581,7 @@
336
581
  {/each}
337
582
  </thead>
338
583
 
339
- <tbody>
584
+ <tbody use:anchorAction>
340
585
  {#if rows.length === 0}
341
586
  <tr>
342
587
  {#if empty}
@@ -346,56 +591,113 @@
346
591
  {/if}
347
592
  </tr>
348
593
  {:else}
349
- {#each rows as row, ri (row.id)}
350
- {@const rp = rowProps?.(row.original, rowItem(row, ri, rows.length)) ?? {}}
351
- {@const attrs = rowAttrs(rp)}
352
- {@const expanded = rowIsExpanded(row)}
353
- <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
354
- <tr
355
- {...attrs}
356
- class={rp.class}
357
- class:dt-row-selected={row.getIsSelected()}
358
- class:dt-row-clickable={!!rp.onclick || !!onRowClick || internalExpand}
359
- class:dt-row-expanded={expanded}
360
- data-row-id={getRowId ? row.id : undefined}
361
- aria-expanded={expandedRow ? expanded : undefined}
362
- tabindex={internalExpand ? (attrs.tabindex ?? 0) : attrs.tabindex}
363
- onclick={hasRowHandlers ? (e) => onRowClickInternal(row, rp, e) : undefined}
364
- onkeydown={internalExpand ? (e) => onRowKeydown(row, e) : undefined}
365
- oncontextmenu={rp.oncontextmenu}
366
- ondragover={rp.ondragover}
367
- ondrop={rp.ondrop}
368
- >
369
- {#each row.getVisibleCells() as cell}
370
- {#if cell.column.id === '__select'}
371
- <td class="dt-select-cell">
372
- <input
373
- type="checkbox"
374
- class="checkbox checkbox-sm"
375
- checked={row.getIsSelected()}
376
- disabled={!row.getCanSelect()}
377
- aria-label="Select row"
378
- onchange={() => row.toggleSelected()}
379
- />
380
- </td>
594
+ {#each slots as slot (slot.entry.key)}
595
+ {#each slot.spacers as h}
596
+ <tr class="dt-spacer" aria-hidden="true" style:height="{h}px"></tr>
597
+ {/each}
598
+ {@const entry = slot.entry}
599
+ {#if entry.kind === 'group'}
600
+ {@const g = entry.item}
601
+ {@const pathKey = entry.pathKey}
602
+ <tr
603
+ class="dt-group-row"
604
+ class:dt-group-sticky={stickyGroupHeaders}
605
+ data-group-key={pathKey}
606
+ data-group-depth={g.depth}
607
+ style:--dt-group-depth={g.depth}
608
+ aria-rowindex={ariaRowIndex(slot.index)}
609
+ aria-level={ariaGrid ? g.depth + 1 : undefined}
610
+ aria-expanded={ariaGrid ? !g.collapsed : undefined}
611
+ use:measureAction={entry.key}
612
+ >
613
+ {#if groupHeader}
614
+ {@render groupHeader(g, {
615
+ toggle: () => toggleGroup(pathKey),
616
+ colspan: allColumns.length,
617
+ collapsed: g.collapsed,
618
+ })}
381
619
  {:else}
382
- {@const rendered = renderCell(cell)}
383
- <td>
384
- {#if rendered.type === 'snippet'}
385
- {@render rendered.snippet(rendered.props)}
386
- {:else}
387
- {rendered.value}
388
- {/if}
389
- </td>
620
+ <th colspan={allColumns.length} scope="rowgroup" class="dt-group-cell">
621
+ <button
622
+ type="button"
623
+ class="dt-group-toggle"
624
+ aria-expanded={!g.collapsed}
625
+ onclick={() => toggleGroup(pathKey)}
626
+ >
627
+ <svg class="dt-group-chevron" class:dt-group-chevron-open={!g.collapsed} width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
628
+ <path d="M4.5 2.5L8 6L4.5 9.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
629
+ </svg>
630
+ <span class="dt-group-label">{g.label}</span>
631
+ <span class="dt-group-count">{g.count}</span>
632
+ </button>
633
+ </th>
390
634
  {/if}
391
- {/each}
392
- </tr>
393
- {#if expanded && expandedRow}
394
- <tr class="dt-expanded-detail" data-detail-for={getRowId ? row.id : undefined}>
635
+ </tr>
636
+ {:else if entry.kind === 'row'}
637
+ {@const row = entry.row}
638
+ {@const rp = rowProps?.(row.original, entry.item) ?? {}}
639
+ {@const attrs = rowAttrs(rp)}
640
+ {@const expanded = rowIsExpanded(row)}
641
+ {@const selected = isSelected(row)}
642
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
643
+ <tr
644
+ {...attrs}
645
+ class={rp.class}
646
+ class:dt-row-selected={selected}
647
+ class:dt-row-clickable={!!rp.onclick || !!onRowClick || internalExpand}
648
+ class:dt-row-expanded={expanded}
649
+ data-row-id={getRowId ? row.id : undefined}
650
+ aria-expanded={expandedRow ? expanded : undefined}
651
+ aria-selected={ariaGrid && selectable ? selected : undefined}
652
+ aria-level={ariaGrid && grouped ? entry.item.depth + 1 : undefined}
653
+ aria-rowindex={ariaRowIndex(slot.index)}
654
+ tabindex={internalExpand ? (attrs.tabindex ?? 0) : attrs.tabindex}
655
+ onclick={hasRowHandlers ? (e) => onRowClickInternal(row, rp, e) : undefined}
656
+ onkeydown={internalExpand ? (e) => onRowKeydown(row, e) : undefined}
657
+ oncontextmenu={rp.oncontextmenu}
658
+ ondragover={rp.ondragover}
659
+ ondrop={rp.ondrop}
660
+ use:measureAction={entry.key}
661
+ >
662
+ {#each row.getVisibleCells() as cell}
663
+ {#if cell.column.id === '__select'}
664
+ <td class="dt-select-cell">
665
+ <input
666
+ type="checkbox"
667
+ class="checkbox checkbox-sm"
668
+ checked={selected}
669
+ disabled={!row.getCanSelect()}
670
+ aria-label="Select row"
671
+ onchange={() => row.toggleSelected()}
672
+ />
673
+ </td>
674
+ {:else}
675
+ {@const rendered = renderCell(cell)}
676
+ <td>
677
+ {#if rendered.type === 'snippet'}
678
+ {@render rendered.snippet(rendered.props)}
679
+ {:else}
680
+ {rendered.value}
681
+ {/if}
682
+ </td>
683
+ {/if}
684
+ {/each}
685
+ </tr>
686
+ {:else if expandedRow}
687
+ {@const row = entry.row}
688
+ <tr
689
+ class="dt-expanded-detail"
690
+ data-detail-for={getRowId ? row.id : undefined}
691
+ aria-rowindex={ariaRowIndex(slot.index)}
692
+ use:measureAction={entry.key}
693
+ >
395
694
  <td colspan={allColumns.length}>{@render expandedRow(row.original, { colspan: allColumns.length })}</td>
396
695
  </tr>
397
696
  {/if}
398
697
  {/each}
698
+ {#if win && win.paddingBottom > 0}
699
+ <tr class="dt-spacer" aria-hidden="true" style:height="{win.paddingBottom}px"></tr>
700
+ {/if}
399
701
  {/if}
400
702
  </tbody>
401
703
  </table>
@@ -542,4 +844,76 @@
542
844
  @media (prefers-reduced-motion: reduce) {
543
845
  .skel-cell { animation: none; }
544
846
  }
847
+
848
+ /* ── Grid mode ─────────────────────────────────────────────────────── */
849
+ .dt-scroller {
850
+ overflow: auto;
851
+ }
852
+ /* Sticky without maxHeight: no scroll container, so sticky cells stick to
853
+ the page's scroller. Horizontal overflow is clipped instead of scrolled. */
854
+ .dt-clip {
855
+ overflow-x: clip;
856
+ }
857
+
858
+ .dt-sticky-head thead th {
859
+ position: sticky;
860
+ top: 0;
861
+ z-index: 3;
862
+ background-color: var(--color-base-100);
863
+ }
864
+
865
+ .dt-spacer {
866
+ border: 0;
867
+ background: transparent !important;
868
+ }
869
+
870
+ .dt-group-sticky > :global(th),
871
+ .dt-group-sticky > :global(td) {
872
+ position: sticky;
873
+ top: calc(var(--dt-thead-h, 0px) + var(--dt-group-depth, 0) * var(--dt-group-row-h, 2.25rem));
874
+ z-index: 2;
875
+ }
876
+
877
+ .dt-group-cell {
878
+ text-align: left;
879
+ padding-left: calc(var(--jatui-space-base, 8px) + var(--dt-group-depth, 0) * var(--dt-group-indent, 1rem));
880
+ background-color: var(--color-base-200);
881
+ font-weight: 600;
882
+ }
883
+
884
+ .dt-group-toggle {
885
+ display: inline-flex;
886
+ align-items: center;
887
+ gap: var(--jatui-space-sm, 4px);
888
+ padding: 0;
889
+ border: 0;
890
+ background: none;
891
+ color: inherit;
892
+ font: inherit;
893
+ cursor: pointer;
894
+ border-radius: var(--jatui-radius-selector, 0.375rem);
895
+ }
896
+ .dt-group-toggle:focus-visible {
897
+ outline: 2px solid var(--color-primary);
898
+ outline-offset: 2px;
899
+ }
900
+
901
+ .dt-group-chevron {
902
+ flex-shrink: 0;
903
+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
904
+ transition: transform var(--motion-fast, 150ms) var(--motion-glide, ease-out);
905
+ }
906
+ .dt-group-chevron-open {
907
+ transform: rotate(90deg);
908
+ }
909
+
910
+ .dt-group-count {
911
+ font-weight: 400;
912
+ font-size: 0.75rem;
913
+ color: color-mix(in oklch, var(--color-base-content) 50%, transparent);
914
+ }
915
+
916
+ @media (prefers-reduced-motion: reduce) {
917
+ .dt-group-chevron { transition: none; }
918
+ }
545
919
  </style>
@@ -2,7 +2,7 @@ import type { DataTableProps } from './types.js';
2
2
  declare function $$render<TData>(): {
3
3
  props: DataTableProps<TData>;
4
4
  exports: {};
5
- bindings: "sorting" | "api";
5
+ bindings: "collapsed" | "sorting" | "api";
6
6
  slots: {};
7
7
  events: {};
8
8
  };
@@ -10,7 +10,7 @@ declare class __sveltets_Render<TData> {
10
10
  props(): ReturnType<typeof $$render<TData>>['props'];
11
11
  events(): ReturnType<typeof $$render<TData>>['events'];
12
12
  slots(): ReturnType<typeof $$render<TData>>['slots'];
13
- bindings(): "sorting" | "api";
13
+ bindings(): "collapsed" | "sorting" | "api";
14
14
  exports(): {};
15
15
  }
16
16
  interface $$IsomorphicComponent {