@joewinke/jatui 0.25.0 → 0.26.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.
package/CLAUDE.md CHANGED
@@ -59,7 +59,7 @@ Then import:
59
59
  | `Badge` | Status/label badge | `variant`, `size`, `children` (snippet) |
60
60
  | `StatusBadge` | Status indicator with dot | `status: StatusValue`, `label` |
61
61
  | `Card` | Content card container | `title`, `children` (snippet) |
62
- | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number` |
62
+ | `Modal` | Dialog overlay. `zIndex` accepts `number` OR any CSS `z-index` string, including `"var(--z-lightbox)"`. **Escape is owned by the dialog element itself** (not the window handler) — Modal focuses the dialog on open, so `trapKeys`' stopPropagation would otherwise swallow Esc before the window listener sees it; a Modal opened inside a host Drawer closes before the drawer. The component ships its own open-state `.modal-box` opacity rule, so it renders under DaisyUI 5 with no host global override. | `open` (bindable), `title`, `children` (snippet), `zIndex?: string \| number`, `trapKeys?`, `closeOnEscape?` |
63
63
  | `ConfirmModal` | Yes/no confirmation dialog | `open` (bindable), `title`, `message`, `onConfirm`, `onCancel` |
64
64
  | `Drawer` | Slide-out side panel | `open` (bindable), `side`, `children` (snippet) |
65
65
  | `LoadingSpinner` | Spinner animation | `size`, `color` |
@@ -76,6 +76,14 @@ Then import:
76
76
  | `QuotaMeter` | "312 / 500 credits · resets in 4h 10m" bar — warning tint at `warnAt`, error at 100%. Presentational; the host owns the ledger. | `used`, `limit`, `resetsAt?`, `label?`, `unit?`, `warnAt?` (0.8), `size?` |
77
77
  | `VariantMatrixPicker` | Category/option chip grid for building a combinatorial variant matrix (harvested from vid-gen's FrameVariantGenerator, presets stripped — caller-supplied `categories`). Each category tab shows a live selection count and an additive/multiplicative badge: multiplicative selections cartesian-multiply the total, additive ones stack flat onto every combination. Live combination count in the footer; algebra exported separately as pure functions (`generateCombinations`, `countCombinations`, `variantCategoryMode`, `findVariantOption`, `generateVariantFilename`). | `categories: VariantCategory[]`, `selected` (bindable `VariantSelection`), `forceMode?`, `showCount?` (true), `countLabel?`, `onChange?`, `class?` |
78
78
 
79
+ ### Tables (subpath: `jatui/table`)
80
+
81
+ Requires peer `@tanstack/table-core`. `import { DataTable, flexRender, columnResize } from 'jatui/table'`.
82
+
83
+ | Component | Purpose | Key Props |
84
+ |-----------|---------|-----------|
85
+ | `DataTable` | TanStack-backed table (generic over the row type): sorting, selection, client pagination, skeleton. Grid-mode hooks per jat `docs/design/datatable-virtualisation-group-rows.md` §3.3: `getRowId` (keys selection/expansion/api by id, renders `data-row-id`); controlled sort — `bind:sorting`, `initialSorting`, `manualSorting` (caller pre-sorts `data`), `onSortingChange(next, {field, shiftKey})`; `rowProps(row, item)` → `{class, attrs, onclick, oncontextmenu, ondragover, ondrop}` (`attrs` for `data-nav-id`, aria); `onRowClick(row, e)` (skips clicks on interactive children); `headerProps(columnId)`; `expandedRow` snippet `(row, {colspan})` — click/Enter toggles unless `isExpanded(row)` makes it controlled; `empty` snippet; `variant` `zebra`/`plain`/`unstyled` + `tableClass`; `bind:api` → `orderedRowIds()`, `scrollToRow(id)`. Sortable headers are keyboard-operable. | `data`, `columns`, `sortable`, `selectable`, `paginated`, `getRowId?`, `sorting?` (bindable), `rowProps?`, `onRowClick?`, `expandedRow?`, `empty?`, `variant?`, `api?` (bindable) |
86
+
79
87
  ### Media
80
88
 
81
89
  | Component | Purpose | Key Props |
@@ -244,7 +252,7 @@ const callbacks: AnnotationCallbacks = {
244
252
  <KeyboardShortcutsOverlay registry={shortcuts} />
245
253
  ```
246
254
 
247
- **Sidebar convention: `SIDEBAR_TOGGLE_KEY` = `mod+\`** (Ctrl+\ / ⌘\). Do NOT use Ctrl+B: Chrome reserves it (bookmarks bar). Also avoid Ctrl+T/W/N, which the browser keeps. The overlay owns `?`, so don't register it. Helpers: `formatShortcut(def | "mod+k", platform?)` → `⌘K` / `Ctrl+K`, `isTypingTarget(el)`, `parseShortcutCombo`, `detectShortcutPlatform`.
255
+ **Sidebar convention: `SIDEBAR_TOGGLE_KEY` = `mod+\`** (Ctrl+\ / ⌘\). Do NOT use Ctrl+B: Chrome reserves it (bookmarks bar). Also avoid Ctrl+T/W/N, which the browser keeps. The overlay owns `?` by default, so don't register it — but if a host app already has its own global `?` handler registered first, the overlay respects `event.defaultPrevented` and stays out of the way (one overlay per keypress). Helpers: `formatShortcut(def | "mod+k", platform?)` → `⌘K` / `Ctrl+K`, `isTypingTarget(el)`, `parseShortcutCombo`, `detectShortcutPlatform`.
248
256
 
249
257
  ### Maps (Google Maps)
250
258
 
@@ -133,6 +133,11 @@
133
133
  }
134
134
 
135
135
  function onKeydown(e: KeyboardEvent) {
136
+ // Another listener already claimed this keypress (host app's own '?'
137
+ // handler, an open modal, …) — bail so one keypress can't toggle two
138
+ // overlays at once. Covers the '?' and Escape branches alike.
139
+ if (e.defaultPrevented) return;
140
+
136
141
  if (e.ctrlKey || e.metaKey || e.altKey) return;
137
142
 
138
143
  if (e.key === '?') {
@@ -124,6 +124,21 @@
124
124
  }
125
125
 
126
126
  function handleKeyDown(event: KeyboardEvent) {
127
+ // Escape is owned HERE on the dialog element, not by <svelte:window>.
128
+ // Modal focuses the dialog (tabindex=-1) on open, so in the default state
129
+ // keydown dispatches from the dialog itself — and the trapKeys
130
+ // stopPropagation below (which under Svelte 5 delegated events also
131
+ // blocks every window listener) would swallow Escape before the window
132
+ // handler could act: Esc did nothing unless focus was in a text field.
133
+ // Handling it at element level also guarantees a Modal opened inside a
134
+ // host Drawer closes before the drawer does.
135
+ if (open && event.key === 'Escape' && closeOnEscape) {
136
+ event.preventDefault();
137
+ event.stopPropagation();
138
+ close();
139
+ return;
140
+ }
141
+
127
142
  // Stop keydown events from bubbling to window while the modal is open.
128
143
  // This prevents page-level keyboard navigation (listNav-style handlers
129
144
  // on window) from reacting to arrow keys, j/k, vim chords, digit counts,
@@ -247,4 +262,21 @@
247
262
  align-items: center;
248
263
  justify-content: center;
249
264
  }
265
+
266
+ /*
267
+ * DaisyUI 5 puts .modal-box at opacity:0 (layer daisyui.l1.l2.l3) and only
268
+ * restores it from `.modal[open] .modal-box { opacity: 1 }` in the outer
269
+ * layer — a rule that assumes daisyUI's canonical markup where the box is a
270
+ * direct grid child of the dialog. jatui nests the box inside
271
+ * .modal-backdrop, and the restore rule loses the cascade: the panel stays
272
+ * invisible unless the host adds a global override (the jat IDE had to in
273
+ * app.css, jat-vte6e.17). Un-layered author styles beat @layer rules, so
274
+ * declaring the open state here makes the component render correctly on
275
+ * its own. Entrance motion stays owned by the in:fly transition on the box.
276
+ */
277
+ :global(dialog.modal[open] .modal-box) {
278
+ opacity: 1;
279
+ translate: 0;
280
+ scale: 1;
281
+ }
250
282
  </style>
@@ -1,4 +1,5 @@
1
- <script lang="ts">
1
+ <script lang="ts" generics="TData">
2
+ import { tick } from 'svelte';
2
3
  import {
3
4
  createTable,
4
5
  getCoreRowModel,
@@ -9,12 +10,10 @@
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 type { DataTableProps, DataTableRowProps, DataTableSortEvent, GroupedItem } from './types.js';
18
17
 
19
18
  let {
20
19
  data,
@@ -27,13 +26,36 @@
27
26
  loadingRows = 5,
28
27
  onSelectionChange,
29
28
  class: className = '',
30
- }: DataTableProps = $props();
29
+ getRowId,
30
+ sorting = $bindable(),
31
+ initialSorting,
32
+ manualSorting = false,
33
+ onSortingChange,
34
+ rowProps,
35
+ onRowClick,
36
+ headerProps,
37
+ expandedRow,
38
+ isExpanded,
39
+ empty,
40
+ variant = 'zebra',
41
+ tableClass = '',
42
+ api = $bindable(),
43
+ }: DataTableProps<TData> = $props();
31
44
 
32
45
  // ── State ──────────────────────────────────────────────────────────────
33
- let sorting = $state<SortingState>([]);
46
+ // `sorting` is bindable: bound, the caller owns it; unbound, it is local
47
+ // state seeded from `initialSorting`.
48
+ // svelte-ignore state_referenced_locally
49
+ if (sorting === undefined) sorting = initialSorting ?? [];
50
+ const sortState = $derived<SortingState>(sorting ?? []);
34
51
  let rowSelection = $state<RowSelectionState>({});
35
52
  // svelte-ignore state_referenced_locally
36
53
  let pagination = $state<PaginationState>({ pageIndex: 0, pageSize });
54
+ /** Internally-owned expansion (only used when `isExpanded` is not supplied). */
55
+ let expandedIds = $state<Set<string>>(new Set());
56
+ /** The header click that triggered the sort change currently in flight. */
57
+ let pendingSortEvent: DataTableSortEvent | null = null;
58
+ let rootEl: HTMLDivElement | undefined = $state();
37
59
 
38
60
  // Reset to the first page whenever the data set changes.
39
61
  //
@@ -50,7 +72,7 @@
50
72
  pagination = { pageIndex: 0, pageSize };
51
73
  });
52
74
 
53
- const checkboxCol: ColumnDef<AnyData, unknown> = {
75
+ const checkboxCol: ColumnDef<TData, unknown> = {
54
76
  id: '__select',
55
77
  header: () => null,
56
78
  cell: () => null,
@@ -58,24 +80,26 @@
58
80
  size: 40,
59
81
  };
60
82
 
61
- const allColumns = $derived<ColumnDef<AnyData, unknown>[]>(
62
- selectable ? [checkboxCol, ...(columns as ColumnDef<AnyData, unknown>[])] : (columns as ColumnDef<AnyData, unknown>[])
63
- );
83
+ const allColumns = $derived<ColumnDef<TData, unknown>[]>(selectable ? [checkboxCol, ...columns] : columns);
64
84
 
65
85
  // ── Table instance ─────────────────────────────────────────────────────
66
86
  const table = $derived.by(() => {
67
- const opts: TableOptionsResolved<AnyData> = {
87
+ const opts: TableOptionsResolved<TData> = {
68
88
  data,
69
89
  columns: allColumns,
70
90
  state: {
71
- sorting,
91
+ sorting: sortState,
72
92
  rowSelection,
73
93
  ...(paginated ? { pagination } : {}),
74
94
  },
95
+ getRowId: getRowId ? (row: TData) => getRowId(row) : undefined,
75
96
  enableRowSelection: selectable,
76
97
  enableSorting: sortable,
98
+ manualSorting,
77
99
  onSortingChange: (updater) => {
78
- sorting = typeof updater === 'function' ? updater(sorting) : updater;
100
+ const next = typeof updater === 'function' ? updater(sortState) : updater;
101
+ sorting = next;
102
+ onSortingChange?.(next, pendingSortEvent ?? { field: next[0]?.id ?? '', shiftKey: false });
79
103
  },
80
104
  onRowSelectionChange: (updater) => {
81
105
  rowSelection = typeof updater === 'function' ? updater(rowSelection) : updater;
@@ -87,7 +111,7 @@
87
111
  : () => {},
88
112
  onStateChange: () => {},
89
113
  getCoreRowModel: getCoreRowModel(),
90
- getSortedRowModel: sortable ? getSortedRowModel() : undefined,
114
+ getSortedRowModel: sortable && !manualSorting ? getSortedRowModel() : undefined,
91
115
  getPaginationRowModel: paginated ? getPaginationRowModel() : undefined,
92
116
  renderFallbackValue: '',
93
117
  };
@@ -109,30 +133,128 @@
109
133
  const canPrevPage = $derived(table.getCanPreviousPage());
110
134
  const canNextPage = $derived(table.getCanNextPage());
111
135
 
136
+ const tableClasses = $derived(
137
+ [variant === 'unstyled' ? '' : variant === 'plain' ? 'table' : 'table table-zebra', 'w-full', tableClass]
138
+ .filter(Boolean)
139
+ .join(' ')
140
+ );
141
+
142
+ /** The table owns expansion: a row click / Enter / Space toggles the detail row. */
143
+ const internalExpand = $derived(!!expandedRow && !isExpanded);
144
+
112
145
  $effect(() => {
113
146
  if (!selectable || !onSelectionChange) return;
114
- const selected = table.getSelectedRowModel().rows.map((r: AnyData) => r.original);
147
+ const selected = table.getSelectedRowModel().rows.map((r) => r.original);
115
148
  onSelectionChange(selected);
116
149
  });
117
150
 
151
+ // ── Imperative api ─────────────────────────────────────────────────────
152
+ api = {
153
+ orderedRowIds: () => table.getPrePaginationRowModel().rows.map((r) => r.id),
154
+ scrollToRow: async (id, align = 'auto') => {
155
+ if (paginated) {
156
+ const index = table.getPrePaginationRowModel().rows.findIndex((r) => r.id === id);
157
+ if (index < 0) return;
158
+ const pageIndex = Math.floor(index / pagination.pageSize);
159
+ if (pageIndex !== pagination.pageIndex) {
160
+ pagination = { ...pagination, pageIndex };
161
+ await tick();
162
+ }
163
+ }
164
+ const el = rootEl?.querySelector<HTMLElement>(`tr[data-row-id="${CSS.escape(id)}"]`);
165
+ el?.scrollIntoView({ block: align === 'auto' ? 'nearest' : align });
166
+ },
167
+ };
168
+
118
169
  function sortDir(headerId: string): 'asc' | 'desc' | false {
119
- const s = sorting.find((item) => item.id === headerId);
170
+ const s = sortState.find((item) => item.id === headerId);
120
171
  return s ? (s.desc ? 'desc' : 'asc') : false;
121
172
  }
122
173
 
123
- function renderHeader(header: ReturnType<typeof table.getHeaderGroups>[0]['headers'][0]) {
174
+ type AnyHeader = ReturnType<typeof table.getHeaderGroups>[0]['headers'][0];
175
+
176
+ function toggleSort(header: AnyHeader, e: MouseEvent | KeyboardEvent) {
177
+ pendingSortEvent = { field: header.column.id, shiftKey: e.shiftKey };
178
+ try {
179
+ header.column.getToggleSortingHandler()?.(e);
180
+ } finally {
181
+ pendingSortEvent = null;
182
+ }
183
+ }
184
+
185
+ function onHeaderKeydown(header: AnyHeader, e: KeyboardEvent) {
186
+ if (e.key !== 'Enter' && e.key !== ' ') return;
187
+ e.preventDefault();
188
+ toggleSort(header, e);
189
+ }
190
+
191
+ function renderHeader(header: AnyHeader) {
124
192
  return flexRender(header.column.columnDef.header, header.getContext());
125
193
  }
126
194
 
127
- function renderCell(cell: ReturnType<ReturnType<typeof table.getRowModel>['rows'][0]['getVisibleCells']>[0]) {
195
+ function renderCell(cell: ReturnType<Row<TData>['getVisibleCells']>[0]) {
128
196
  return flexRender(cell.column.columnDef.cell, cell.getContext());
129
197
  }
198
+
199
+ // ── 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 };
203
+ }
204
+
205
+ function rowIsExpanded(row: Row<TData>): boolean {
206
+ if (!expandedRow) return false;
207
+ return isExpanded ? isExpanded(row.original) : expandedIds.has(row.id);
208
+ }
209
+
210
+ function toggleExpanded(id: string) {
211
+ const next = new Set(expandedIds);
212
+ if (next.has(id)) next.delete(id);
213
+ else next.add(id);
214
+ expandedIds = next;
215
+ }
216
+
217
+ /** Clicks on these inside a row belong to the control, not to the row. */
218
+ const INTERACTIVE = 'input, button, a, select, textarea, label, [contenteditable]:not([contenteditable="false"])';
219
+
220
+ function isInteractiveTarget(e: Event): boolean {
221
+ const target = e.target as Element | null;
222
+ const tr = e.currentTarget as Element;
223
+ const hit = target?.closest?.(INTERACTIVE);
224
+ return !!hit && hit !== tr && tr.contains(hit);
225
+ }
226
+
227
+ function onRowClickInternal(row: Row<TData>, rp: DataTableRowProps, e: MouseEvent) {
228
+ if (isInteractiveTarget(e)) return;
229
+ rp.onclick?.(e);
230
+ onRowClick?.(row.original, e);
231
+ if (internalExpand) toggleExpanded(row.id);
232
+ }
233
+
234
+ function onRowKeydown(row: Row<TData>, e: KeyboardEvent) {
235
+ if (!internalExpand || e.target !== e.currentTarget) return;
236
+ if (e.key !== 'Enter' && e.key !== ' ') return;
237
+ e.preventDefault();
238
+ toggleExpanded(row.id);
239
+ }
240
+
241
+ /** Normalise `rowProps().attrs`: booleans become `""`/omitted (aria-* stay "true"/"false"). */
242
+ function rowAttrs(rp: DataTableRowProps): Record<string, string | number | undefined> {
243
+ const out: Record<string, string | number | undefined> = {};
244
+ for (const [k, v] of Object.entries(rp.attrs ?? {})) {
245
+ if (typeof v === 'boolean') out[k] = k.startsWith('aria-') ? String(v) : v ? '' : undefined;
246
+ else out[k] = v;
247
+ }
248
+ return out;
249
+ }
250
+
251
+ const hasRowHandlers = $derived(!!rowProps || !!onRowClick || internalExpand);
130
252
  </script>
131
253
 
132
- <div class="datatable-root {className}">
254
+ <div class="datatable-root {className}" bind:this={rootEl}>
133
255
  {#if loading}
134
256
  <div class="overflow-x-auto">
135
- <table class="table table-zebra w-full">
257
+ <table class={tableClasses}>
136
258
  <thead>
137
259
  <tr>
138
260
  {#each allColumns as _}
@@ -155,7 +277,7 @@
155
277
  </div>
156
278
  {:else}
157
279
  <div class="overflow-x-auto">
158
- <table class="table table-zebra w-full">
280
+ <table class={tableClasses}>
159
281
  <thead>
160
282
  {#each headerGroups as headerGroup}
161
283
  <tr>
@@ -175,10 +297,15 @@
175
297
  {@const isSortable = sortable && header.column.getCanSort()}
176
298
  {@const dir = sortDir(header.column.id)}
177
299
  {@const rendered = renderHeader(header)}
300
+ {@const hp = headerProps?.(header.column.id)}
178
301
  <th
179
302
  style:width={header.getSize() !== 150 ? `${header.getSize()}px` : undefined}
303
+ class={hp?.class}
180
304
  class:dt-th-sortable={isSortable}
181
- onclick={isSortable ? () => header.column.toggleSorting() : undefined}
305
+ tabindex={isSortable ? 0 : undefined}
306
+ onclick={isSortable ? (e) => toggleSort(header, e) : undefined}
307
+ onkeydown={isSortable ? (e) => onHeaderKeydown(header, e) : undefined}
308
+ oncontextmenu={hp?.oncontextmenu}
182
309
  aria-sort={dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : undefined}
183
310
  >
184
311
  <span class="dt-th-label">
@@ -212,11 +339,33 @@
212
339
  <tbody>
213
340
  {#if rows.length === 0}
214
341
  <tr>
215
- <td colspan={allColumns.length} class="dt-empty">No data</td>
342
+ {#if empty}
343
+ <td colspan={allColumns.length} class="dt-empty-custom">{@render empty()}</td>
344
+ {:else}
345
+ <td colspan={allColumns.length} class="dt-empty">No data</td>
346
+ {/if}
216
347
  </tr>
217
348
  {:else}
218
- {#each rows as row}
219
- <tr class:dt-row-selected={row.getIsSelected()}>
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
+ >
220
369
  {#each row.getVisibleCells() as cell}
221
370
  {#if cell.column.id === '__select'}
222
371
  <td class="dt-select-cell">
@@ -241,12 +390,16 @@
241
390
  {/if}
242
391
  {/each}
243
392
  </tr>
393
+ {#if expanded && expandedRow}
394
+ <tr class="dt-expanded-detail" data-detail-for={getRowId ? row.id : undefined}>
395
+ <td colspan={allColumns.length}>{@render expandedRow(row.original, { colspan: allColumns.length })}</td>
396
+ </tr>
397
+ {/if}
244
398
  {/each}
245
399
  {/if}
246
400
  </tbody>
247
401
  </table>
248
402
  </div>
249
-
250
403
  {#if paginated && pageCount > 1}
251
404
  <div class="dt-pagination">
252
405
  <span class="dt-page-info">
@@ -327,6 +480,14 @@
327
480
  background-color: color-mix(in oklch, var(--color-primary) 6%, transparent) !important;
328
481
  }
329
482
 
483
+ .dt-row-clickable {
484
+ cursor: pointer;
485
+ }
486
+ .dt-row-clickable:focus-visible {
487
+ outline: 2px solid var(--color-primary);
488
+ outline-offset: -2px;
489
+ }
490
+
330
491
  .dt-select-cell {
331
492
  width: 2.5rem;
332
493
  text-align: center;
@@ -1,4 +1,25 @@
1
1
  import type { DataTableProps } from './types.js';
2
- declare const DataTable: import("svelte").Component<DataTableProps<any>, {}, "">;
3
- type DataTable = ReturnType<typeof DataTable>;
2
+ declare function $$render<TData>(): {
3
+ props: DataTableProps<TData>;
4
+ exports: {};
5
+ bindings: "sorting" | "api";
6
+ slots: {};
7
+ events: {};
8
+ };
9
+ declare class __sveltets_Render<TData> {
10
+ props(): ReturnType<typeof $$render<TData>>['props'];
11
+ events(): ReturnType<typeof $$render<TData>>['events'];
12
+ slots(): ReturnType<typeof $$render<TData>>['slots'];
13
+ bindings(): "sorting" | "api";
14
+ exports(): {};
15
+ }
16
+ interface $$IsomorphicComponent {
17
+ new <TData>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<TData>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<TData>['props']>, ReturnType<__sveltets_Render<TData>['events']>, ReturnType<__sveltets_Render<TData>['slots']>> & {
18
+ $$bindings?: ReturnType<__sveltets_Render<TData>['bindings']>;
19
+ } & ReturnType<__sveltets_Render<TData>['exports']>;
20
+ <TData>(internal: unknown, props: ReturnType<__sveltets_Render<TData>['props']> & {}): ReturnType<__sveltets_Render<TData>['exports']>;
21
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
22
+ }
23
+ declare const DataTable: $$IsomorphicComponent;
24
+ type DataTable<TData> = InstanceType<typeof DataTable<TData>>;
4
25
  export default DataTable;
@@ -2,4 +2,4 @@ export { default as DataTable } from './DataTable.svelte';
2
2
  export { flexRender } from './flexRender';
3
3
  export { columnResize } from './columnResize';
4
4
  export type { ColumnResizeOptions } from './columnResize';
5
- export type { DataTableProps, SelectionEvent, ColumnDef, Row } from './types';
5
+ export type { DataTableProps, DataTableApi, DataTableRowProps, DataTableHeaderProps, DataTableSortEvent, DataTableVariant, GroupedItem, SelectionEvent, ColumnDef, Row, SortingState } from './types';
@@ -1,5 +1,63 @@
1
- import type { ColumnDef, Row } from '@tanstack/table-core';
2
- export type { ColumnDef, Row };
1
+ import type { Snippet } from 'svelte';
2
+ import type { ColumnDef, Row, SortingState } from '@tanstack/table-core';
3
+ export type { ColumnDef, Row, SortingState };
4
+ /**
5
+ * One entry in the table's render list. Today DataTable renders flat rows, so
6
+ * every item is `kind: 'row'` with `depth: 0`; the `group` variant is the shape
7
+ * the grouped render list (`groupRows`, design doc
8
+ * jat/docs/design/datatable-virtualisation-group-rows.md §3.2) will produce, so
9
+ * `rowProps` callers written now keep working when grouping lands.
10
+ */
11
+ export type GroupedItem<T = any> = {
12
+ kind: 'group';
13
+ key: string;
14
+ path: string[];
15
+ depth: number;
16
+ label: string;
17
+ rows: T[];
18
+ count: number;
19
+ collapsed: boolean;
20
+ } | {
21
+ kind: 'row';
22
+ row: T;
23
+ id: string;
24
+ depth: number;
25
+ groupPath: string[];
26
+ position: 'first' | 'middle' | 'last' | 'only';
27
+ };
28
+ /** What `rowProps` may put on a body `<tr>`. */
29
+ export interface DataTableRowProps {
30
+ class?: string;
31
+ /** Extra attributes (`data-*`, `aria-*`, `tabindex`…). `false` omits the attribute. */
32
+ attrs?: Record<string, string | number | boolean | undefined>;
33
+ onclick?: (e: MouseEvent) => void;
34
+ oncontextmenu?: (e: MouseEvent) => void;
35
+ ondragover?: (e: DragEvent) => void;
36
+ ondrop?: (e: DragEvent) => void;
37
+ }
38
+ /** What `headerProps` may put on a header `<th>`. */
39
+ export interface DataTableHeaderProps {
40
+ class?: string;
41
+ oncontextmenu?: (e: MouseEvent) => void;
42
+ }
43
+ /** Context passed alongside a sorting change: which header, and whether it was a shift-click. */
44
+ export interface DataTableSortEvent {
45
+ field: string;
46
+ shiftKey: boolean;
47
+ }
48
+ /** Imperative handle, exposed through `bind:api`. */
49
+ export interface DataTableApi {
50
+ /** Scroll a row into view (jumping to its page first when paginated). */
51
+ scrollToRow(id: string, align?: 'auto' | 'start' | 'center'): void;
52
+ /** Row ids in render order across ALL pages — what keyboard nav should walk. */
53
+ orderedRowIds(): string[];
54
+ }
55
+ /**
56
+ * Table skin. `zebra` (default) = DaisyUI `table table-zebra`; `plain` drops the
57
+ * striping; `unstyled` drops the DaisyUI table classes entirely so the host's
58
+ * own CSS owns the look.
59
+ */
60
+ export type DataTableVariant = 'zebra' | 'plain' | 'unstyled';
3
61
  export interface DataTableProps<TData = any> {
4
62
  /** Row data array */
5
63
  data: TData[];
@@ -19,8 +77,53 @@ export interface DataTableProps<TData = any> {
19
77
  loadingRows?: number;
20
78
  /** Emitted when the selection set changes */
21
79
  onSelectionChange?: (rows: TData[]) => void;
22
- /** Additional table class */
80
+ /** Additional class on the root wrapper */
23
81
  class?: string;
82
+ /**
83
+ * Stable row identity (e.g. a task id). Keys selection, expansion and `api`
84
+ * by id instead of index, so a poll that reorders `data` keeps state on the
85
+ * right rows. Also renders `data-row-id` on each body `<tr>`.
86
+ */
87
+ getRowId?: (row: TData) => string;
88
+ /** Controlled sort state — `bind:sorting`. Unbound, the table owns it. */
89
+ sorting?: SortingState;
90
+ /** Starting sort state when `sorting` is not supplied (e.g. restored from localStorage). */
91
+ initialSorting?: SortingState;
92
+ /**
93
+ * true = DataTable does not sort; pass `data` already sorted and react to
94
+ * header clicks through `onSortingChange`. Headers still show the sort state.
95
+ */
96
+ manualSorting?: boolean;
97
+ /** Fires on every header-driven sort change — persist it here. */
98
+ onSortingChange?: (next: SortingState, ev: DataTableSortEvent) => void;
99
+ /** Per-row class / attributes / handlers for the body `<tr>`. */
100
+ rowProps?: (row: TData, item: GroupedItem<TData>) => DataTableRowProps;
101
+ /**
102
+ * Shorthand for `rowProps(...).onclick`. Not fired for clicks that land on an
103
+ * interactive child (input, button, link, select, textarea, label,
104
+ * contenteditable) — so the select checkbox and inline editors stay inert.
105
+ */
106
+ onRowClick?: (row: TData, e: MouseEvent) => void;
107
+ /** Per-column class / context-menu handler for the header `<th>`. */
108
+ headerProps?: (columnId: string) => DataTableHeaderProps;
109
+ /**
110
+ * Detail row rendered under an expanded row, in one full-width cell.
111
+ * Without `isExpanded`, the table owns expansion: a row click (or Enter /
112
+ * Space on the focused row) toggles it.
113
+ */
114
+ expandedRow?: Snippet<[TData, {
115
+ colspan: number;
116
+ }]>;
117
+ /** Controlled expansion — when given, the caller owns which rows are open. */
118
+ isExpanded?: (row: TData) => boolean;
119
+ /** Replaces the default "No data" cell when `data` is empty. */
120
+ empty?: Snippet;
121
+ /** `zebra` (default) | `plain` | `unstyled` — see DataTableVariant. */
122
+ variant?: DataTableVariant;
123
+ /** Extra class on the `<table>` element (e.g. `table-sm`). */
124
+ tableClass?: string;
125
+ /** Imperative handle — `bind:api`. */
126
+ api?: DataTableApi;
24
127
  }
25
128
  export interface SelectionEvent<TData> {
26
129
  rows: TData[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",