@joewinke/jatui 0.25.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 (74) hide show
  1. package/CLAUDE.md +115 -2
  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/KeyboardShortcutsOverlay.svelte +5 -0
  23. package/dist/components/Modal.svelte +50 -1
  24. package/dist/components/Modal.svelte.d.ts +8 -0
  25. package/dist/components/basic/Empty.svelte +173 -9
  26. package/dist/components/basic/Empty.svelte.d.ts +50 -1
  27. package/dist/components/basic/ErrorState.svelte +128 -0
  28. package/dist/components/basic/ErrorState.svelte.d.ts +46 -0
  29. package/dist/components/basic/JumpToLatest.svelte +60 -0
  30. package/dist/components/basic/JumpToLatest.svelte.d.ts +9 -0
  31. package/dist/components/basic/PageHeader.svelte +216 -0
  32. package/dist/components/basic/PageHeader.svelte.d.ts +52 -0
  33. package/dist/components/basic/ProgressButton.svelte +221 -0
  34. package/dist/components/basic/ProgressButton.svelte.d.ts +63 -0
  35. package/dist/components/graph/GraphApiBridge.svelte +36 -0
  36. package/dist/components/graph/GraphApiBridge.svelte.d.ts +9 -0
  37. package/dist/components/graph/GraphCanvas.svelte +212 -27
  38. package/dist/components/graph/GraphCanvas.svelte.d.ts +113 -19
  39. package/dist/components/graph/api.d.ts +52 -0
  40. package/dist/components/graph/api.js +66 -0
  41. package/dist/components/graph/index.d.ts +2 -0
  42. package/dist/components/graph/index.js +1 -0
  43. package/dist/components/graph/theme.js +13 -0
  44. package/dist/components/messaging/MessageItem.svelte +5 -0
  45. package/dist/components/messaging/MessageItem.svelte.d.ts +7 -0
  46. package/dist/components/table/DataTable.svelte +607 -72
  47. package/dist/components/table/DataTable.svelte.d.ts +23 -2
  48. package/dist/components/table/ManageColumnsDropdown.svelte +466 -0
  49. package/dist/components/table/ManageColumnsDropdown.svelte.d.ts +26 -0
  50. package/dist/components/table/columnStorage.d.ts +29 -0
  51. package/dist/components/table/columnStorage.js +61 -0
  52. package/dist/components/table/groupRows.d.ts +72 -0
  53. package/dist/components/table/groupRows.js +118 -0
  54. package/dist/components/table/index.d.ts +7 -1
  55. package/dist/components/table/index.js +3 -0
  56. package/dist/components/table/types.d.ts +207 -3
  57. package/dist/index.d.ts +25 -2
  58. package/dist/index.js +32 -1
  59. package/dist/utils/ansiToHtml.d.ts +83 -0
  60. package/dist/utils/ansiToHtml.js +543 -0
  61. package/dist/utils/keyboardShortcutRegistry.d.ts +332 -11
  62. package/dist/utils/keyboardShortcutRegistry.js +1070 -76
  63. package/dist/utils/pollBackoff.d.ts +83 -0
  64. package/dist/utils/pollBackoff.js +151 -0
  65. package/dist/utils/snapFlick.d.ts +59 -0
  66. package/dist/utils/snapFlick.js +66 -0
  67. package/dist/video/advisors/qr.d.ts +1 -1
  68. package/dist/virtual/createRowWindow.d.ts +75 -0
  69. package/dist/virtual/createRowWindow.js +129 -0
  70. package/dist/virtual/index.d.ts +4 -0
  71. package/dist/virtual/index.js +5 -0
  72. package/dist/virtual/rowWindowCore.d.ts +202 -0
  73. package/dist/virtual/rowWindowCore.js +510 -0
  74. package/package.json +11 -1
@@ -1,4 +1,5 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="TData">
2
+ import { tick, untrack } from 'svelte';
2
3
  import {
3
4
  createTable,
4
5
  getCoreRowModel,
@@ -9,12 +10,18 @@
9
10
  type RowSelectionState,
10
11
  type PaginationState,
11
12
  type TableOptionsResolved,
13
+ type Row,
12
14
  } from '@tanstack/table-core';
13
15
  import { flexRender } from './flexRender.js';
14
- import type { DataTableProps } from './types.js';
15
-
16
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
- type AnyData = any;
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';
18
25
 
19
26
  let {
20
27
  data,
@@ -27,13 +34,51 @@
27
34
  loadingRows = 5,
28
35
  onSelectionChange,
29
36
  class: className = '',
30
- }: DataTableProps = $props();
37
+ getRowId,
38
+ sorting = $bindable(),
39
+ initialSorting,
40
+ manualSorting = false,
41
+ onSortingChange,
42
+ rowProps,
43
+ onRowClick,
44
+ headerProps,
45
+ expandedRow,
46
+ isExpanded,
47
+ empty,
48
+ variant = 'zebra',
49
+ tableClass = '',
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,
63
+ }: DataTableProps<TData> = $props();
31
64
 
32
65
  // ── State ──────────────────────────────────────────────────────────────
33
- let sorting = $state<SortingState>([]);
66
+ // `sorting` is bindable: bound, the caller owns it; unbound, it is local
67
+ // state seeded from `initialSorting`.
68
+ // svelte-ignore state_referenced_locally
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();
73
+ const sortState = $derived<SortingState>(sorting ?? []);
34
74
  let rowSelection = $state<RowSelectionState>({});
35
75
  // svelte-ignore state_referenced_locally
36
76
  let pagination = $state<PaginationState>({ pageIndex: 0, pageSize });
77
+ /** Internally-owned expansion (only used when `isExpanded` is not supplied). */
78
+ let expandedIds = $state<Set<string>>(new Set());
79
+ /** The header click that triggered the sort change currently in flight. */
80
+ let pendingSortEvent: DataTableSortEvent | null = null;
81
+ let rootEl: HTMLDivElement | undefined = $state();
37
82
 
38
83
  // Reset to the first page whenever the data set changes.
39
84
  //
@@ -50,7 +95,7 @@
50
95
  pagination = { pageIndex: 0, pageSize };
51
96
  });
52
97
 
53
- const checkboxCol: ColumnDef<AnyData, unknown> = {
98
+ const checkboxCol: ColumnDef<TData, unknown> = {
54
99
  id: '__select',
55
100
  header: () => null,
56
101
  cell: () => null,
@@ -58,24 +103,37 @@
58
103
  size: 40,
59
104
  };
60
105
 
61
- const allColumns = $derived<ColumnDef<AnyData, unknown>[]>(
62
- selectable ? [checkboxCol, ...(columns as ColumnDef<AnyData, unknown>[])] : (columns as ColumnDef<AnyData, unknown>[])
63
- );
106
+ const allColumns = $derived<ColumnDef<TData, unknown>[]>(selectable ? [checkboxCol, ...columns] : columns);
64
107
 
65
108
  // ── Table instance ─────────────────────────────────────────────────────
66
- const table = $derived.by(() => {
67
- const opts: TableOptionsResolved<AnyData> = {
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 {
68
122
  data,
69
123
  columns: allColumns,
70
124
  state: {
71
- sorting,
125
+ sorting: sortState,
72
126
  rowSelection,
73
127
  ...(paginated ? { pagination } : {}),
74
128
  },
129
+ getRowId: getRowId ? (row: TData) => getRowId(row) : undefined,
75
130
  enableRowSelection: selectable,
76
131
  enableSorting: sortable,
132
+ manualSorting,
77
133
  onSortingChange: (updater) => {
78
- sorting = typeof updater === 'function' ? updater(sorting) : updater;
134
+ const next = typeof updater === 'function' ? updater(sortState) : updater;
135
+ sorting = next;
136
+ onSortingChange?.(next, pendingSortEvent ?? { field: next[0]?.id ?? '', shiftKey: false });
79
137
  },
80
138
  onRowSelectionChange: (updater) => {
81
139
  rowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
@@ -86,53 +144,351 @@
86
144
  }
87
145
  : () => {},
88
146
  onStateChange: () => {},
89
- getCoreRowModel: getCoreRowModel(),
90
- getSortedRowModel: sortable ? getSortedRowModel() : undefined,
91
- getPaginationRowModel: paginated ? getPaginationRowModel() : undefined,
147
+ getCoreRowModel: coreRowModel,
148
+ getSortedRowModel: sortable && !manualSorting ? sortedRowModel : undefined,
149
+ getPaginationRowModel: paginated ? paginationRowModel : undefined,
92
150
  renderFallbackValue: '',
93
151
  };
94
- const instance = createTable(opts);
95
- // createTable takes a fully-resolved state — unlike the framework adapters it does
96
- // not fill in defaults, so feature state we never touch (columnPinning, columnVisibility,
97
- // …) lands undefined and getHeaderGroups() dies reading columnPinning.left. Merge the
98
- // instance defaults under our reactive slices, the same way the TanStack adapters do.
99
- 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) => ({
100
178
  ...prev,
101
- state: { ...instance.initialState, ...opts.state },
179
+ ...opts,
180
+ state: { ...table.initialState, ...opts.state },
102
181
  }));
103
- return instance;
182
+ return {};
104
183
  });
105
184
 
106
- const headerGroups = $derived(table.getHeaderGroups());
107
- const rows = $derived(table.getRowModel().rows);
108
- const pageCount = $derived(table.getPageCount());
109
- const canPrevPage = $derived(table.getCanPreviousPage());
110
- 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());
196
+
197
+ const tableClasses = $derived(
198
+ [variant === 'unstyled' ? '' : variant === 'plain' ? 'table' : 'table table-zebra', 'w-full', tableClass]
199
+ .filter(Boolean)
200
+ .join(' ')
201
+ );
202
+
203
+ /** The table owns expansion: a row click / Enter / Space toggles the detail row. */
204
+ const internalExpand = $derived(!!expandedRow && !isExpanded);
111
205
 
112
206
  $effect(() => {
113
207
  if (!selectable || !onSelectionChange) return;
114
- const selected = table.getSelectedRowModel().rows.map((r: AnyData) => r.original);
208
+ const selected = t().getSelectedRowModel().rows.map((r) => r.original);
115
209
  onSelectionChange(selected);
116
210
  });
117
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
+
361
+ // ── Imperative api ─────────────────────────────────────────────────────
362
+ api = {
363
+ orderedRowIds: () =>
364
+ grouped && !paginated
365
+ ? entries.filter((e) => e.kind === 'row').map((e) => e.key)
366
+ : t().getPrePaginationRowModel().rows.map((r) => r.id),
367
+ scrollToRow: async (id, align = 'auto') => {
368
+ if (paginated) {
369
+ const index = t().getPrePaginationRowModel().rows.findIndex((r) => r.id === id);
370
+ if (index < 0) return;
371
+ const pageIndex = Math.floor(index / pagination.pageSize);
372
+ if (pageIndex !== pagination.pageIndex) {
373
+ pagination = { ...pagination, pageIndex };
374
+ await tick();
375
+ }
376
+ }
377
+ if (win) {
378
+ win.scrollToKey(id, { align });
379
+ return;
380
+ }
381
+ const el = rootEl?.querySelector<HTMLElement>(`tr[data-row-id="${CSS.escape(id)}"]`);
382
+ el?.scrollIntoView({ block: align === 'auto' ? 'nearest' : align });
383
+ },
384
+ };
385
+
118
386
  function sortDir(headerId: string): 'asc' | 'desc' | false {
119
- const s = sorting.find((item) => item.id === headerId);
387
+ const s = sortState.find((item) => item.id === headerId);
120
388
  return s ? (s.desc ? 'desc' : 'asc') : false;
121
389
  }
122
390
 
123
- function renderHeader(header: ReturnType<typeof table.getHeaderGroups>[0]['headers'][0]) {
391
+ type AnyHeader = ReturnType<typeof table.getHeaderGroups>[0]['headers'][0];
392
+
393
+ function toggleSort(header: AnyHeader, e: MouseEvent | KeyboardEvent) {
394
+ pendingSortEvent = { field: header.column.id, shiftKey: e.shiftKey };
395
+ try {
396
+ header.column.getToggleSortingHandler()?.(e);
397
+ } finally {
398
+ pendingSortEvent = null;
399
+ }
400
+ }
401
+
402
+ function onHeaderKeydown(header: AnyHeader, e: KeyboardEvent) {
403
+ if (e.key !== 'Enter' && e.key !== ' ') return;
404
+ e.preventDefault();
405
+ toggleSort(header, e);
406
+ }
407
+
408
+ function renderHeader(header: AnyHeader) {
124
409
  return flexRender(header.column.columnDef.header, header.getContext());
125
410
  }
126
411
 
127
- function renderCell(cell: ReturnType<ReturnType<typeof table.getRowModel>['rows'][0]['getVisibleCells']>[0]) {
412
+ function renderCell(cell: ReturnType<Row<TData>['getVisibleCells']>[0]) {
128
413
  return flexRender(cell.column.columnDef.cell, cell.getContext());
129
414
  }
415
+
416
+ // ── Row helpers ────────────────────────────────────────────────────────
417
+ function isSelected(row: Row<TData>): boolean {
418
+ void tableRev;
419
+ return row.getIsSelected();
420
+ }
421
+
422
+ function rowIsExpanded(row: Row<TData>): boolean {
423
+ if (!expandedRow) return false;
424
+ return isExpanded ? isExpanded(row.original) : expandedIds.has(row.id);
425
+ }
426
+
427
+ function toggleExpanded(id: string) {
428
+ const next = new Set(expandedIds);
429
+ if (next.has(id)) next.delete(id);
430
+ else next.add(id);
431
+ expandedIds = next;
432
+ }
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
+
442
+ /** Clicks on these inside a row belong to the control, not to the row. */
443
+ const INTERACTIVE = 'input, button, a, select, textarea, label, [contenteditable]:not([contenteditable="false"])';
444
+
445
+ function isInteractiveTarget(e: Event): boolean {
446
+ const target = e.target as Element | null;
447
+ const tr = e.currentTarget as Element;
448
+ const hit = target?.closest?.(INTERACTIVE);
449
+ return !!hit && hit !== tr && tr.contains(hit);
450
+ }
451
+
452
+ function onRowClickInternal(row: Row<TData>, rp: DataTableRowProps, e: MouseEvent) {
453
+ if (isInteractiveTarget(e)) return;
454
+ rp.onclick?.(e);
455
+ onRowClick?.(row.original, e);
456
+ if (internalExpand) toggleExpanded(row.id);
457
+ }
458
+
459
+ function onRowKeydown(row: Row<TData>, e: KeyboardEvent) {
460
+ if (!internalExpand || e.target !== e.currentTarget) return;
461
+ if (e.key !== 'Enter' && e.key !== ' ') return;
462
+ e.preventDefault();
463
+ toggleExpanded(row.id);
464
+ }
465
+
466
+ /** Normalise `rowProps().attrs`: booleans become `""`/omitted (aria-* stay "true"/"false"). */
467
+ function rowAttrs(rp: DataTableRowProps): Record<string, string | number | undefined> {
468
+ const out: Record<string, string | number | undefined> = {};
469
+ for (const [k, v] of Object.entries(rp.attrs ?? {})) {
470
+ if (typeof v === 'boolean') out[k] = k.startsWith('aria-') ? String(v) : v ? '' : undefined;
471
+ else out[k] = v;
472
+ }
473
+ return out;
474
+ }
475
+
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);
130
486
  </script>
131
487
 
132
- <div class="datatable-root {className}">
488
+ <div class="datatable-root {className}" bind:this={rootEl}>
133
489
  {#if loading}
134
490
  <div class="overflow-x-auto">
135
- <table class="table table-zebra w-full">
491
+ <table class={tableClasses}>
136
492
  <thead>
137
493
  <tr>
138
494
  {#each allColumns as _}
@@ -154,19 +510,30 @@
154
510
  </table>
155
511
  </div>
156
512
  {:else}
157
- <div class="overflow-x-auto">
158
- <table class="table table-zebra w-full">
159
- <thead>
160
- {#each headerGroups as headerGroup}
161
- <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}>
162
529
  {#each headerGroup.headers as header}
163
530
  {#if header.column.id === '__select'}
164
531
  <th class="dt-select-cell">
165
532
  <input
166
533
  type="checkbox"
167
534
  class="checkbox checkbox-sm"
168
- checked={table.getIsAllRowsSelected()}
169
- indeterminate={table.getIsSomeRowsSelected()}
535
+ checked={t().getIsAllRowsSelected()}
536
+ indeterminate={t().getIsSomeRowsSelected()}
170
537
  aria-label="Select all rows"
171
538
  onchange={() => table.toggleAllRowsSelected()}
172
539
  />
@@ -175,10 +542,15 @@
175
542
  {@const isSortable = sortable && header.column.getCanSort()}
176
543
  {@const dir = sortDir(header.column.id)}
177
544
  {@const rendered = renderHeader(header)}
545
+ {@const hp = headerProps?.(header.column.id)}
178
546
  <th
179
547
  style:width={header.getSize() !== 150 ? `${header.getSize()}px` : undefined}
548
+ class={hp?.class}
180
549
  class:dt-th-sortable={isSortable}
181
- onclick={isSortable ? () => header.column.toggleSorting() : undefined}
550
+ tabindex={isSortable ? 0 : undefined}
551
+ onclick={isSortable ? (e) => toggleSort(header, e) : undefined}
552
+ onkeydown={isSortable ? (e) => onHeaderKeydown(header, e) : undefined}
553
+ oncontextmenu={hp?.oncontextmenu}
182
554
  aria-sort={dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : undefined}
183
555
  >
184
556
  <span class="dt-th-label">
@@ -209,44 +581,127 @@
209
581
  {/each}
210
582
  </thead>
211
583
 
212
- <tbody>
584
+ <tbody use:anchorAction>
213
585
  {#if rows.length === 0}
214
586
  <tr>
215
- <td colspan={allColumns.length} class="dt-empty">No data</td>
587
+ {#if empty}
588
+ <td colspan={allColumns.length} class="dt-empty-custom">{@render empty()}</td>
589
+ {:else}
590
+ <td colspan={allColumns.length} class="dt-empty">No data</td>
591
+ {/if}
216
592
  </tr>
217
593
  {:else}
218
- {#each rows as row}
219
- <tr class:dt-row-selected={row.getIsSelected()}>
220
- {#each row.getVisibleCells() as cell}
221
- {#if cell.column.id === '__select'}
222
- <td class="dt-select-cell">
223
- <input
224
- type="checkbox"
225
- class="checkbox checkbox-sm"
226
- checked={row.getIsSelected()}
227
- disabled={!row.getCanSelect()}
228
- aria-label="Select row"
229
- onchange={() => row.toggleSelected()}
230
- />
231
- </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
+ })}
232
619
  {:else}
233
- {@const rendered = renderCell(cell)}
234
- <td>
235
- {#if rendered.type === 'snippet'}
236
- {@render rendered.snippet(rendered.props)}
237
- {:else}
238
- {rendered.value}
239
- {/if}
240
- </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>
241
634
  {/if}
242
- {/each}
243
- </tr>
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
+ >
694
+ <td colspan={allColumns.length}>{@render expandedRow(row.original, { colspan: allColumns.length })}</td>
695
+ </tr>
696
+ {/if}
244
697
  {/each}
698
+ {#if win && win.paddingBottom > 0}
699
+ <tr class="dt-spacer" aria-hidden="true" style:height="{win.paddingBottom}px"></tr>
700
+ {/if}
245
701
  {/if}
246
702
  </tbody>
247
703
  </table>
248
704
  </div>
249
-
250
705
  {#if paginated && pageCount > 1}
251
706
  <div class="dt-pagination">
252
707
  <span class="dt-page-info">
@@ -327,6 +782,14 @@
327
782
  background-color: color-mix(in oklch, var(--color-primary) 6%, transparent) !important;
328
783
  }
329
784
 
785
+ .dt-row-clickable {
786
+ cursor: pointer;
787
+ }
788
+ .dt-row-clickable:focus-visible {
789
+ outline: 2px solid var(--color-primary);
790
+ outline-offset: -2px;
791
+ }
792
+
330
793
  .dt-select-cell {
331
794
  width: 2.5rem;
332
795
  text-align: center;
@@ -381,4 +844,76 @@
381
844
  @media (prefers-reduced-motion: reduce) {
382
845
  .skel-cell { animation: none; }
383
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
+ }
384
919
  </style>