runeforge 0.0.52 → 0.0.54

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 (46) hide show
  1. package/README.md +128 -26
  2. package/dist/components/crud/GenericCRUD.svelte +18 -23
  3. package/dist/components/crud/GenericCRUD.svelte.d.ts +6 -9
  4. package/dist/components/crud/utils/embedded.d.ts +1 -0
  5. package/dist/components/crud/utils/embedded.js +14 -0
  6. package/dist/components/crud/utils/formatters.d.ts +0 -1
  7. package/dist/components/crud/utils/formatters.js +0 -6
  8. package/dist/components/crud/utils/reorder.d.ts +4 -0
  9. package/dist/components/crud/utils/reorder.js +13 -0
  10. package/dist/components/crud/utils/resolution.d.ts +1 -0
  11. package/dist/components/crud/utils/resolution.js +11 -0
  12. package/dist/components/crud/views/Create.svelte +2 -1
  13. package/dist/components/crud/views/Update.svelte +2 -2
  14. package/dist/components/crud/views/list/List.svelte +291 -0
  15. package/dist/components/crud/views/{List.svelte.d.ts → list/List.svelte.d.ts} +4 -14
  16. package/dist/components/crud/views/list/Modals.svelte +56 -0
  17. package/dist/components/crud/views/list/Modals.svelte.d.ts +36 -0
  18. package/dist/components/crud/views/list/Table.svelte +176 -0
  19. package/dist/components/crud/views/list/Table.svelte.d.ts +59 -0
  20. package/dist/components/crud/views/list/Toolbar.svelte +341 -0
  21. package/dist/components/crud/views/list/Toolbar.svelte.d.ts +46 -0
  22. package/dist/components/table/PaginatedTable.svelte +361 -16
  23. package/dist/components/table/PaginatedTable.svelte.d.ts +11 -2
  24. package/dist/components/table/TableBody.svelte +83 -3
  25. package/dist/components/table/TableBody.svelte.d.ts +14 -1
  26. package/dist/components/table/TableHeader.svelte +9 -4
  27. package/dist/components/table/TableHeader.svelte.d.ts +1 -0
  28. package/dist/components/table/sortable.d.ts +27 -0
  29. package/dist/components/table/sortable.js +55 -0
  30. package/dist/components/table/utils.d.ts +19 -1
  31. package/dist/components/table/utils.js +40 -0
  32. package/dist/i18n/en.js +2 -0
  33. package/dist/i18n/es.js +2 -0
  34. package/dist/i18n/types.d.ts +2 -0
  35. package/dist/icons/defaults/Grip.svelte +7 -0
  36. package/dist/icons/defaults/Grip.svelte.d.ts +7 -0
  37. package/dist/icons/sets/bootstrap.js +2 -1
  38. package/dist/icons/sets/default.js +2 -0
  39. package/dist/icons/types.d.ts +3 -0
  40. package/dist/index.d.ts +7 -6
  41. package/dist/index.js +5 -4
  42. package/dist/types/attribute.d.ts +8 -0
  43. package/dist/types/crud.d.ts +79 -2
  44. package/dist/types/table.d.ts +56 -0
  45. package/package.json +3 -1
  46. package/dist/components/crud/views/List.svelte +0 -584
@@ -4,15 +4,20 @@
4
4
  import Paginator from './Paginator.svelte';
5
5
  import TableHeader from './TableHeader.svelte';
6
6
  import { SortState, FilterState, snapshotFilter } from './state.svelte.js';
7
- import { distinctEntries, isFilterable } from './utils.js';
7
+ import {
8
+ distinctEntries,
9
+ isFilterable,
10
+ resolveReorderComparator
11
+ } from './utils.js';
8
12
  import type {
9
13
  FilterSnapshot,
10
14
  IndexedRow,
15
+ ReorderOptions,
11
16
  ServerPagination,
12
17
  SortDirection,
13
18
  TableQuery
14
19
  } from '../../types/table.js';
15
- import type { Snippet } from 'svelte';
20
+ import { tick, type Snippet } from 'svelte';
16
21
  import type { ColumnDefinition } from '../../types/crud.js';
17
22
  import { getStrings } from '../../i18n/context.js';
18
23
 
@@ -31,7 +36,9 @@
31
36
  initialFilters = undefined as Partial<FilterSnapshot> | undefined,
32
37
  onPaginationChange = undefined as ((query: TableQuery) => void) | undefined,
33
38
  visibleRows = $bindable<T[]>([]),
34
- query = $bindable<TableQuery | undefined>(undefined)
39
+ query = $bindable<TableQuery | undefined>(undefined),
40
+ reorder = undefined as ReorderOptions<T> | undefined,
41
+ onReorder = undefined as ((rows: T[]) => void) | undefined
35
42
  }: {
36
43
  data?: T[];
37
44
  columns?: ColumnDefinition<T>[];
@@ -53,6 +60,15 @@
53
60
  /** Current ordering + filters snapshot, kept in sync for callers that
54
61
  * need to replicate the active query (e.g. exporting server-side). */
55
62
  query?: TableQuery;
63
+ /** Turns on drag-to-reorder. Per-column filters are suppressed and row
64
+ * order is fully owned by `reorder.compare`/`reorder.attribute` while
65
+ * it's active — ignored entirely in server-pagination mode
66
+ * (`pagination` set), since the full row set needs to be reachable
67
+ * client-side for drag positions to be meaningful. */
68
+ reorder?: ReorderOptions<T>;
69
+ /** Fires after a drag settles with the complete reordered row list
70
+ * (client mode only). The caller decides how to persist it. */
71
+ onReorder?: (rows: T[]) => void;
56
72
  } = $props();
57
73
 
58
74
  // Intentional one-time hydration of local state from the initial prop
@@ -100,24 +116,103 @@
100
116
  : distinctEntries(data, columns)
101
117
  );
102
118
 
119
+ // Server-pagination mode owns its own full row set server-side, where
120
+ // drag positions can't be reconciled across pages — reorder only makes
121
+ // sense once the whole (client-mode) row set is reachable.
122
+ const reorderActive = $derived(!!reorder && !pagination);
123
+
103
124
  const indexed = $derived(data.map((row, index): IndexedRow<T> => ({ row, index })));
125
+ // Dragging needs the complete row set reachable, so filters (which would
126
+ // hide rows) are bypassed while reorder is active.
104
127
  const filtered = $derived(
105
- pagination ? indexed : indexed.filter(({ row }) => filter.matches(row, columns))
128
+ pagination || reorderActive ? indexed : indexed.filter(({ row }) => filter.matches(row, columns))
129
+ );
130
+ const sorted = $derived(pagination || reorderActive ? filtered : sort.apply(filtered, columns));
131
+
132
+ // Reorder mode owns ordering outright (column-header sorting is disabled
133
+ // while it's active — see TableHeader), via `reorder.compare` when given
134
+ // (composite orders) or plain ascending by `reorder.attribute`.
135
+ const reorderComparator = $derived(reorder ? resolveReorderComparator(reorder) : undefined);
136
+ const reorderBase = $derived(
137
+ reorderActive ? [...indexed].sort((a, b) => reorderComparator!(a.row, b.row)) : []
106
138
  );
107
- const sorted = $derived(pagination ? filtered : sort.apply(filtered, columns));
139
+
140
+ // Local override applied on top of `reorderBase` once the user starts
141
+ // dragging, so the displayed order doesn't snap back on the next reactive
142
+ // update while persistence is in flight — cleared whenever `data` changes
143
+ // reference from anywhere else (e.g. a reload after persisting).
144
+ let manualOrder = $state<IndexedRow<T>[] | null>(null);
145
+ let lastReorderData: T[] | undefined;
146
+ $effect(() => {
147
+ if (data !== lastReorderData) {
148
+ manualOrder = null;
149
+ lastReorderData = data;
150
+ }
151
+ });
152
+ // The full reordered row list (all of it — pagination during reorder mode
153
+ // is a visual window over this, not a slice; see `visibleRange` below).
154
+ const reorderIndexed = $derived(reorderActive ? (manualOrder ?? reorderBase) : []);
155
+
156
+ // Escape cancels an in-progress drag — see the `keydown` listener below.
157
+ // SortableJS has no public "abort" API, so cancelling means: let the drag
158
+ // settle normally (wherever the pointer happens to be), then discard that
159
+ // outcome here instead of persisting it.
160
+ let cancelReorder = false;
161
+
162
+ async function handleReorderSettled(rows: IndexedRow<T>[]) {
163
+ // TableBody's `onEnd` fires this on every drag that settles, whether or
164
+ // not any row actually moved — the natural place to also clear the
165
+ // dragging/hover-zone state.
166
+ isDragging = false;
167
+ resetHoverZone();
168
+ if (cancelReorder) {
169
+ cancelReorder = false;
170
+ // `manualOrder` was `null` for the whole drag — it's only ever set
171
+ // here, at settle time — while SortableJS was moving the *actual*
172
+ // DOM nodes around live, entirely outside Svelte's own bookkeeping
173
+ // for this keyed each block. So right now Svelte's internal model
174
+ // of "current order" still says the original order, same as what
175
+ // we want to revert to — setting `manualOrder` straight to that is
176
+ // a no-op *to Svelte* (nothing to reconcile from its point of
177
+ // view), leaving the real DOM stuck wherever SortableJS dropped it.
178
+ // Sync to `rows` (where it actually settled) first — a real change
179
+ // Svelte will apply — then revert on the next tick, which is now a
180
+ // real change too.
181
+ manualOrder = rows;
182
+ await tick();
183
+ manualOrder = [...reorderBase];
184
+ return;
185
+ }
186
+ manualOrder = rows;
187
+ onReorder?.(rows.map((e) => e.row));
188
+ }
108
189
 
109
190
  const effectivePageSize = $derived(pagination?.pageSize ?? pageSize);
110
191
  const totalPages = $derived(
111
- pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize)
192
+ reorderActive
193
+ ? Math.max(1, Math.ceil(reorderIndexed.length / effectivePageSize))
194
+ : (pagination?.totalPages ?? Math.ceil(sorted.length / effectivePageSize))
112
195
  );
113
196
  const displayPage = $derived(pagination?.page ?? currentPage);
114
197
  const pageStart = $derived((displayPage - 1) * effectivePageSize);
198
+ const pageEnd = $derived(pageStart + effectivePageSize);
199
+ // Reorder mode: TableBody gets the *entire* reordered set (so a drag can
200
+ // reach across a page flip without SortableJS losing track of rows), with
201
+ // `visibleRange` telling it which positions are actually on screen.
115
202
  const pageData = $derived(
116
- pagination ? sorted : sorted.slice(pageStart, pageStart + effectivePageSize)
203
+ reorderActive ? reorderIndexed : pagination ? sorted : sorted.slice(pageStart, pageEnd)
204
+ );
205
+ const visiblePageData = $derived(
206
+ reorderActive ? reorderIndexed.slice(pageStart, pageEnd) : pageData
117
207
  );
118
- const totalCount = $derived(pagination?.total ?? sorted.length);
119
- const allChecked = $derived(pageData.length > 0 && pageData.every((e) => selected.has(e.index)));
120
- const someChecked = $derived(pageData.some((e) => selected.has(e.index)));
208
+ const visibleRange = $derived(
209
+ reorderActive ? { start: pageStart, end: Math.min(pageEnd, reorderIndexed.length) } : undefined
210
+ );
211
+ const totalCount = $derived(reorderActive ? reorderIndexed.length : (pagination?.total ?? sorted.length));
212
+ const allChecked = $derived(
213
+ visiblePageData.length > 0 && visiblePageData.every((e) => selected.has(e.index))
214
+ );
215
+ const someChecked = $derived(visiblePageData.some((e) => selected.has(e.index)));
121
216
 
122
217
  // Client mode only: server mode's totalPages is externally owned, clamping
123
218
  // here would fight with URL-driven navigation while a page reload is pending.
@@ -141,9 +236,10 @@
141
236
  }
142
237
  });
143
238
 
144
- // Surface the filtered+sorted rows and current query for callers (e.g. export).
239
+ // Surface the filtered+sorted rows and current query for callers (e.g.
240
+ // export) — the full set pre-pagination in both regular and reorder mode.
145
241
  $effect(() => {
146
- visibleRows = sorted.map((e) => e.row);
242
+ visibleRows = (reorderActive ? reorderIndexed : sorted).map((e) => e.row);
147
243
  });
148
244
  $effect(() => {
149
245
  query = currentQuery(displayPage);
@@ -165,8 +261,8 @@
165
261
  }
166
262
 
167
263
  function toggleAll() {
168
- if (allChecked) pageData.forEach((e) => selected.delete(e.index));
169
- else pageData.forEach((e) => selected.add(e.index));
264
+ if (allChecked) visiblePageData.forEach((e) => selected.delete(e.index));
265
+ else visiblePageData.forEach((e) => selected.add(e.index));
170
266
  }
171
267
 
172
268
  function toggleItem(index: number) {
@@ -174,11 +270,255 @@
174
270
  else selected.add(index);
175
271
  }
176
272
 
177
- const colCount = $derived(columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0));
273
+ const colCount = $derived(
274
+ columns.length + (selectable ? 1 : 0) + (rowActions ? 1 : 0) + (reorderActive ? 1 : 0)
275
+ );
276
+
277
+ // ─── Drag-to-reorder: edge hover zones for flipping pages mid-drag ──────
278
+ //
279
+ // Hit-testing is computed from `wrapperEl`'s own layout box, not from the
280
+ // visual zone `<div>`s' rendered (Tailwind-classed) dimensions — the
281
+ // mechanic must keep working even if a consumer's Tailwind setup can't
282
+ // generate those utilities for some reason. The `<div>`s stay purely
283
+ // cosmetic, styled the same way as the rest of the library.
284
+
285
+ const ZONE_WIDTH_PX = 56;
286
+
287
+ let isDragging = $state(false);
288
+ let wrapperEl: HTMLElement | undefined = $state();
289
+ let leftProgress = $state(0);
290
+ let rightProgress = $state(0);
291
+ let hoverZone: 'left' | 'right' | null = null;
292
+ let hoverStart = 0;
293
+ let hoverTimer: ReturnType<typeof setInterval> | undefined;
294
+
295
+ // The "Pág N" label + chevron is positioned via JS (`position: fixed`),
296
+ // not CSS `sticky` — the zone spans the *whole* table, which can be much
297
+ // taller than the viewport, and every sticky-based attempt at keeping the
298
+ // label in view broke down somewhere: `top: 50%` resolves against the
299
+ // containing block's height (the full table), not the viewport, so it
300
+ // doesn't track scroll position at all; a `height: 100vh` sticky trick
301
+ // tracks scroll correctly through the top and middle of the table but
302
+ // still overshoots past the viewport once less than one viewport's worth
303
+ // of table remains below the current scroll position (confirmed with a
304
+ // 1611px table in a 900px viewport: correct at scroll 0 and 300, but the
305
+ // label ended up above the viewport, y=-335, once scrolled near the
306
+ // bottom). Computing the clamped position directly sidesteps all of it.
307
+ let labelCenterY = $state(0);
308
+ let labelLeft = $state(0);
309
+ let labelRight = $state(0);
310
+ // Measured off whichever label is currently mounted, so the clamp below
311
+ // knows the label's actual (responsive) height instead of a guessed
312
+ // constant — `prev`/`next` render identically, so either one will do.
313
+ let prevLabelEl: HTMLElement | undefined = $state();
314
+ let nextLabelEl: HTMLElement | undefined = $state();
315
+
316
+ function updateLabelPosition() {
317
+ const box = wrapperEl?.getBoundingClientRect();
318
+ if (!box) return;
319
+ if (box.top >= 0 && box.bottom <= window.innerHeight) {
320
+ // The whole zone already fits on screen — just center the label in
321
+ // it. Nothing to chase the viewport for here, and it keeps the
322
+ // label sitting in the visual middle of the zone instead of
323
+ // pinned to whichever edge the viewport-center math below would
324
+ // clamp it to.
325
+ labelCenterY = (box.top + box.bottom) / 2;
326
+ labelLeft = box.left;
327
+ labelRight = box.right;
328
+ return;
329
+ }
330
+ // The zone is taller than the viewport (or scrolled partly out of
331
+ // it): clamp to the viewport's vertical center, but never past the
332
+ // zone's own top/bottom — so it doesn't float outside the table (or
333
+ // off somewhere odd).
334
+ //
335
+ // That clamp alone only keeps the label's *center* inside the zone —
336
+ // the label itself (chevron + text, `-translate-y-1/2`'d around that
337
+ // center) can still stick out past the top or bottom edge. Pull the
338
+ // clamp in by half the label's own height to keep the whole label
339
+ // inside. When the zone is shorter than the label, full containment
340
+ // is impossible either way — center on the zone so it overflows
341
+ // evenly on both sides rather than being clipped on just one.
342
+ const halfLabel = (prevLabelEl ?? nextLabelEl)?.getBoundingClientRect().height ?? 0;
343
+ const minY = box.top + halfLabel / 2;
344
+ const maxY = box.bottom - halfLabel / 2;
345
+ const target = Math.min(Math.max(window.innerHeight / 2, box.top), box.bottom);
346
+ labelCenterY = minY <= maxY ? Math.min(Math.max(target, minY), maxY) : (box.top + box.bottom) / 2;
347
+ labelLeft = box.left;
348
+ labelRight = box.right;
349
+ }
350
+
351
+ const pageFlipThresholdMs = $derived(reorder?.pageFlipThresholdMs ?? 2000);
352
+
353
+ function resetHoverZone() {
354
+ if (hoverTimer) {
355
+ clearInterval(hoverTimer);
356
+ hoverTimer = undefined;
357
+ }
358
+ hoverZone = null;
359
+ leftProgress = 0;
360
+ rightProgress = 0;
361
+ }
362
+
363
+ function tickHoverZone() {
364
+ if (!hoverZone) return;
365
+ const elapsed = performance.now() - hoverStart;
366
+ const progress = Math.min(1, elapsed / pageFlipThresholdMs);
367
+ if (hoverZone === 'left') leftProgress = progress;
368
+ else rightProgress = progress;
369
+ if (progress >= 1) {
370
+ if (hoverZone === 'left' && currentPage > 1) currentPage--;
371
+ else if (hoverZone === 'right' && currentPage < totalPages) currentPage++;
372
+ // Keep hovering to flip again — dwell time restarts from here.
373
+ hoverStart = performance.now();
374
+ }
375
+ }
376
+
377
+ function enterZone(zone: 'left' | 'right') {
378
+ if (hoverZone === zone) return;
379
+ if (hoverTimer) clearInterval(hoverTimer);
380
+ hoverZone = zone;
381
+ hoverStart = performance.now();
382
+ leftProgress = 0;
383
+ rightProgress = 0;
384
+ hoverTimer = setInterval(tickHoverZone, 80);
385
+ }
386
+
387
+ function handleDragStart() {
388
+ isDragging = true;
389
+ updateLabelPosition();
390
+ // The label divs aren't mounted yet on this first call (they only
391
+ // render once `isDragging` flips, which Svelte applies to the DOM
392
+ // after this synchronous call returns) — `halfLabel` above falls back
393
+ // to 0 for this one frame. Re-run once they exist so the height-aware
394
+ // clamp kicks in immediately rather than waiting for the next pointer
395
+ // move or scroll.
396
+ tick().then(updateLabelPosition);
397
+ }
398
+
399
+ function handleDragMove(clientX: number, clientY: number) {
400
+ const box = wrapperEl?.getBoundingClientRect();
401
+ if (!box || clientY < box.top || clientY > box.bottom) {
402
+ resetHoverZone();
403
+ return;
404
+ }
405
+ if (clientX >= box.left && clientX <= box.left + ZONE_WIDTH_PX) enterZone('left');
406
+ else if (clientX <= box.right && clientX >= box.right - ZONE_WIDTH_PX) enterZone('right');
407
+ else resetHoverZone();
408
+ }
409
+
410
+ // SortableJS's own `onMove` is about "should this reordering happen", not
411
+ // general cursor tracking — it doesn't fire once the pointer strays from
412
+ // a valid drop target (e.g. into the edge margin), so the page-flip zones
413
+ // track the raw pointer directly via document-level listeners instead,
414
+ // active only while a drag is in progress. `dragover` (not `mousemove`)
415
+ // is what actually fires during a *native* HTML5 drag — which reorder
416
+ // uses instead of the mouse-simulated fallback whenever `multiDrag` is
417
+ // on — so both are wired up; whichever the current drag mode emits wins.
418
+ $effect(() => {
419
+ if (!isDragging) return;
420
+ function onPointerMove(e: MouseEvent | TouchEvent | DragEvent) {
421
+ updateLabelPosition();
422
+ const point = 'touches' in e ? e.touches[0] : e;
423
+ if (point) handleDragMove(point.clientX, point.clientY);
424
+ }
425
+ // Scrolling without moving the pointer (mouse wheel mid-drag, or the
426
+ // page having scrolled before the pointer ever reaches the zone) needs
427
+ // to reposition the label too — it's visible (at low opacity) for the
428
+ // whole drag, not just while actively hovering a zone.
429
+ function onScroll() {
430
+ updateLabelPosition();
431
+ }
432
+ document.addEventListener('mousemove', onPointerMove);
433
+ document.addEventListener('touchmove', onPointerMove);
434
+ document.addEventListener('dragover', onPointerMove);
435
+ window.addEventListener('scroll', onScroll, { passive: true });
436
+ return () => {
437
+ document.removeEventListener('mousemove', onPointerMove);
438
+ document.removeEventListener('touchmove', onPointerMove);
439
+ document.removeEventListener('dragover', onPointerMove);
440
+ window.removeEventListener('scroll', onScroll);
441
+ };
442
+ });
443
+
444
+ $effect(() => () => {
445
+ if (hoverTimer) clearInterval(hoverTimer);
446
+ });
447
+
448
+ // Escape cancels the drag in progress. SortableJS only knows how to
449
+ // finish a drag, not abort one, so this ends it the normal way (a real
450
+ // `mouseup` is what its fallback dragging listens for — see sortable.ts)
451
+ // and `handleReorderSettled` discards whatever it settled on instead of
452
+ // persisting it.
453
+ $effect(() => {
454
+ if (!isDragging) return;
455
+ function onKeyDown(e: KeyboardEvent) {
456
+ if (e.key !== 'Escape') return;
457
+ cancelReorder = true;
458
+ // SortableJS listens for `pointerup` when the browser supports Pointer
459
+ // Events (`options.supportPointer`, on by default) and only falls back
460
+ // to plain `mouseup`/`touchend` otherwise — dispatch both so this
461
+ // works regardless of which mode is active.
462
+ document.dispatchEvent(
463
+ new PointerEvent('pointerup', { bubbles: true, cancelable: true, pointerType: 'mouse' })
464
+ );
465
+ document.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
466
+ }
467
+ document.addEventListener('keydown', onKeyDown);
468
+ return () => document.removeEventListener('keydown', onKeyDown);
469
+ });
178
470
  </script>
179
471
 
180
472
  <div class="flex flex-col gap-6">
181
- <div class="min-w-0 overflow-x-auto rounded-box border border-base-content/10">
473
+ <div bind:this={wrapperEl} class="relative min-w-0 overflow-x-auto rounded-box border border-base-content/10">
474
+ {#if reorderActive && isDragging && totalPages > 1}
475
+ {#if currentPage > 1}
476
+ <div
477
+ data-reorder-page-zone="prev"
478
+ class="pointer-events-none absolute inset-y-0 left-0 z-10 w-12 bg-primary transition-opacity duration-150 sm:w-14"
479
+ style="opacity: {0.35 + leftProgress * 0.55}"
480
+ aria-hidden="true"
481
+ ></div>
482
+ <!-- Positioned via JS (`labelCenterY`/`labelLeft`, computed in
483
+ `updateLabelPosition`), not CSS `sticky` — see that function's
484
+ comment for why: nothing sticky-based reliably stayed within the
485
+ viewport across the whole scroll range of a table taller than it. -->
486
+ <div
487
+ bind:this={prevLabelEl}
488
+ data-reorder-page-label="prev"
489
+ class="pointer-events-none fixed z-20 flex w-12 -translate-y-1/2 flex-col items-center
490
+ gap-1 text-primary-content sm:w-14"
491
+ style="top: {labelCenterY}px; left: {labelLeft}px;"
492
+ aria-hidden="true"
493
+ >
494
+ <span class="text-lg leading-none drop-shadow-sm sm:text-xl">‹</span>
495
+ <span class="text-center text-[10px] leading-tight font-semibold drop-shadow-sm sm:text-xs">
496
+ {strings.reorderPage(currentPage - 1)}
497
+ </span>
498
+ </div>
499
+ {/if}
500
+ {#if currentPage < totalPages}
501
+ <div
502
+ data-reorder-page-zone="next"
503
+ class="pointer-events-none absolute inset-y-0 right-0 z-10 w-12 bg-primary transition-opacity duration-150 sm:w-14"
504
+ style="opacity: {0.35 + rightProgress * 0.55}"
505
+ aria-hidden="true"
506
+ ></div>
507
+ <div
508
+ bind:this={nextLabelEl}
509
+ data-reorder-page-label="next"
510
+ class="pointer-events-none fixed z-20 flex w-12 -translate-y-1/2 flex-col items-center
511
+ gap-1 text-primary-content sm:w-14"
512
+ style="top: {labelCenterY}px; left: {labelRight - ZONE_WIDTH_PX}px;"
513
+ aria-hidden="true"
514
+ >
515
+ <span class="text-lg leading-none drop-shadow-sm sm:text-xl">›</span>
516
+ <span class="text-center text-[10px] leading-tight font-semibold drop-shadow-sm sm:text-xs">
517
+ {strings.reorderPage(currentPage + 1)}
518
+ </span>
519
+ </div>
520
+ {/if}
521
+ {/if}
182
522
  <table class="table table-zebra table-xs w-full text-xs sm:table-md sm:text-base">
183
523
  <TableHeader
184
524
  {columns}
@@ -191,6 +531,7 @@
191
531
  {distinctValues}
192
532
  hasRowActions={!!rowActions}
193
533
  {actionsLabel}
534
+ {reorderActive}
194
535
  onchange={handleHeaderChange}
195
536
  />
196
537
  <TableBody
@@ -201,6 +542,10 @@
201
542
  onToggle={toggleItem}
202
543
  {colCount}
203
544
  {rowActions}
545
+ reorder={reorderActive ? reorder : undefined}
546
+ {visibleRange}
547
+ onDragStart={handleDragStart}
548
+ onReorder={handleReorderSettled}
204
549
  />
205
550
  </table>
206
551
  </div>
@@ -1,6 +1,6 @@
1
1
  import { SvelteSet } from 'svelte/reactivity';
2
- import type { FilterSnapshot, ServerPagination, SortDirection, TableQuery } from '../../types/table.js';
3
- import type { Snippet } from 'svelte';
2
+ import type { FilterSnapshot, ReorderOptions, ServerPagination, SortDirection, TableQuery } from '../../types/table.js';
3
+ import { type Snippet } from 'svelte';
4
4
  import type { ColumnDefinition } from '../../types/crud.js';
5
5
  declare function $$render<T extends object = Record<string, unknown>>(): {
6
6
  props: {
@@ -27,6 +27,15 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
27
27
  /** Current ordering + filters snapshot, kept in sync for callers that
28
28
  * need to replicate the active query (e.g. exporting server-side). */
29
29
  query?: TableQuery;
30
+ /** Turns on drag-to-reorder. Per-column filters are suppressed and row
31
+ * order is fully owned by `reorder.compare`/`reorder.attribute` while
32
+ * it's active — ignored entirely in server-pagination mode
33
+ * (`pagination` set), since the full row set needs to be reachable
34
+ * client-side for drag positions to be meaningful. */
35
+ reorder?: ReorderOptions<T>;
36
+ /** Fires after a drag settles with the complete reordered row list
37
+ * (client mode only). The caller decides how to persist it. */
38
+ onReorder?: (rows: T[]) => void;
30
39
  };
31
40
  exports: {};
32
41
  bindings: "selected" | "visibleRows" | "query";
@@ -2,7 +2,15 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { SvelteSet } from 'svelte/reactivity';
4
4
  import type { ColumnDefinition } from '../../types/crud.js';
5
- import type { IndexedRow } from '../../types/table.js';
5
+ import type { IndexedRow, ReorderOptions } from '../../types/table.js';
6
+ import { sortableRows, type SortEndIndices } from './sortable.js';
7
+ import { moveIndexedRows } from './utils.js';
8
+ import Button from '../form/Button.svelte';
9
+ import { getIconSet } from '../../icons/context.js';
10
+ import { defaultIconSet } from '../../icons/sets/default.js';
11
+ import { getStrings } from '../../i18n/context.js';
12
+
13
+ const strings = getStrings();
6
14
 
7
15
  let {
8
16
  columns,
@@ -12,6 +20,10 @@
12
20
  onToggle,
13
21
  colCount,
14
22
  rowActions,
23
+ reorder,
24
+ visibleRange,
25
+ onDragStart,
26
+ onReorder,
15
27
  }: {
16
28
  columns: ColumnDefinition<T>[];
17
29
  rows: IndexedRow<T>[];
@@ -20,10 +32,56 @@
20
32
  onToggle: (index: number) => void;
21
33
  colCount: number;
22
34
  rowActions?: Snippet<[T]>;
35
+ reorder?: ReorderOptions<T>;
36
+ /** Reorder mode only: positions within `rows` (not the `index` field)
37
+ * that are actually on screen — the rest render `hidden` so they stay in
38
+ * the DOM (and reachable by SortableJS) across a page flip mid-drag. */
39
+ visibleRange?: { start: number; end: number };
40
+ onDragStart?: () => void;
41
+ /** Fires once a drag settles, with the complete row list in its new
42
+ * order — computed from SortableJS's own before/after indices, correct
43
+ * uniformly for a single-row drag and a `multiDrag` group move alike. */
44
+ onReorder?: (rows: IndexedRow<T>[]) => void;
23
45
  } = $props();
46
+
47
+ const icons = $derived(getIconSet() ?? defaultIconSet);
48
+
49
+ let tbodyEl: HTMLElement | undefined = $state();
50
+
51
+ function handleDragEnd(indices: SortEndIndices) {
52
+ const fromIndices =
53
+ indices.oldIndicies.length > 0 ? indices.oldIndicies : [indices.oldIndex ?? -1];
54
+ const toIndex =
55
+ indices.newIndicies.length > 0 ? Math.min(...indices.newIndicies) : (indices.newIndex ?? -1);
56
+ onReorder?.(moveIndexedRows(rows, fromIndices, toIndex));
57
+ }
58
+
59
+ // Mirrors the checkbox selection into SortableJS's own MultiDrag selection
60
+ // registry, so a `multiDrag` drag moves whatever's currently checked
61
+ // instead of relying on the plugin's own click/modifier-key selection UX.
62
+ $effect(() => {
63
+ const utils = reorder?.multiDrag ? reorder.sortable.utils : undefined;
64
+ if (!utils || !tbodyEl) return;
65
+ for (const { index } of rows) {
66
+ const el = tbodyEl.querySelector(`[data-row-key="${index}"]`);
67
+ if (!(el instanceof HTMLElement)) continue;
68
+ if (selected.has(index)) utils.select(el);
69
+ else utils.deselect(el);
70
+ }
71
+ });
24
72
  </script>
25
73
 
26
- <tbody data-testid="paginated-table-body">
74
+ <tbody
75
+ data-testid="paginated-table-body"
76
+ bind:this={tbodyEl}
77
+ use:sortableRows={{
78
+ enabled: !!reorder,
79
+ sortable: reorder?.sortable,
80
+ multiDrag: reorder?.multiDrag,
81
+ onStart: () => onDragStart?.(),
82
+ onEnd: handleDragEnd,
83
+ }}
84
+ >
27
85
  {#if rows.length === 0}
28
86
  <tr>
29
87
  <td colspan={colCount} class="py-10 text-center text-base-content/50">
@@ -31,12 +89,34 @@
31
89
  </td>
32
90
  </tr>
33
91
  {:else}
34
- {#each rows as { row, index } (index)}
92
+ {#each rows as { row, index }, position (index)}
93
+ {@const isHidden = !!visibleRange && (position < visibleRange.start || position >= visibleRange.end)}
35
94
  <tr
36
95
  class="hover"
96
+ class:group={!!reorder}
37
97
  class:cursor-pointer={selectable}
98
+ data-row-key={index}
99
+ style:display={isHidden ? 'none' : null}
38
100
  onclick={selectable ? () => onToggle(index) : undefined}
39
101
  >
102
+ {#if reorder}
103
+ {@const GripIcon = reorder.icon ?? icons.grip}
104
+ <td class="w-10 border-l-4 border-base-content/10 p-0 group-hover:border-primary/50">
105
+ <Button
106
+ type="button"
107
+ btn={false}
108
+ data-reorder-handle
109
+ class="flex h-full w-full cursor-grab items-center justify-center py-2 text-base-content/40 active:cursor-grabbing"
110
+ title={strings.reorder}
111
+ aria-label={strings.reorder}
112
+ onclick={(e) => e.stopPropagation()}
113
+ >
114
+ {#if GripIcon}
115
+ <GripIcon class="size-4" />
116
+ {/if}
117
+ </Button>
118
+ </td>
119
+ {/if}
40
120
  {#if selectable}
41
121
  <td>
42
122
  <input
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { SvelteSet } from 'svelte/reactivity';
3
3
  import type { ColumnDefinition } from '../../types/crud.js';
4
- import type { IndexedRow } from '../../types/table.js';
4
+ import type { IndexedRow, ReorderOptions } from '../../types/table.js';
5
5
  declare function $$render<T extends object>(): {
6
6
  props: {
7
7
  columns: ColumnDefinition<T>[];
@@ -11,6 +11,19 @@ declare function $$render<T extends object>(): {
11
11
  onToggle: (index: number) => void;
12
12
  colCount: number;
13
13
  rowActions?: Snippet<[T]>;
14
+ reorder?: ReorderOptions<T>;
15
+ /** Reorder mode only: positions within `rows` (not the `index` field)
16
+ * that are actually on screen — the rest render `hidden` so they stay in
17
+ * the DOM (and reachable by SortableJS) across a page flip mid-drag. */
18
+ visibleRange?: {
19
+ start: number;
20
+ end: number;
21
+ };
22
+ onDragStart?: () => void;
23
+ /** Fires once a drag settles, with the complete row list in its new
24
+ * order — computed from SortableJS's own before/after indices, correct
25
+ * uniformly for a single-row drag and a `multiDrag` group move alike. */
26
+ onReorder?: (rows: IndexedRow<T>[]) => void;
14
27
  };
15
28
  exports: {};
16
29
  bindings: "";
@@ -20,6 +20,7 @@
20
20
  distinctValues,
21
21
  hasRowActions = false,
22
22
  actionsLabel = strings.actions,
23
+ reorderActive = false,
23
24
  onchange,
24
25
  }: {
25
26
  columns: ColumnDefinition<T>[];
@@ -32,11 +33,12 @@
32
33
  distinctValues: Record<string, DistinctEntry<T>[]>;
33
34
  hasRowActions?: boolean;
34
35
  actionsLabel?: string;
36
+ reorderActive?: boolean;
35
37
  onchange?: () => void;
36
38
  } = $props();
37
39
 
38
40
  function sortBy(col: ColumnDefinition<T>) {
39
- if (!isSortable(col)) return;
41
+ if (reorderActive || !isSortable(col)) return;
40
42
  sort.cycle(col.attribute);
41
43
  onchange?.();
42
44
  }
@@ -44,6 +46,9 @@
44
46
 
45
47
  <thead class="bg-base-200">
46
48
  <tr>
49
+ {#if reorderActive}
50
+ <th class="w-10"></th>
51
+ {/if}
47
52
  {#if selectable}
48
53
  <th>
49
54
  <input
@@ -61,11 +66,11 @@
61
66
  <div class="flex items-center gap-1">
62
67
  <SortHeader
63
68
  {title}
64
- sortable={isSortable(col)}
65
- direction={sort.directionFor(col.attribute)}
69
+ sortable={isSortable(col) && !reorderActive}
70
+ direction={reorderActive ? null : sort.directionFor(col.attribute)}
66
71
  onsort={() => sortBy(col)}
67
72
  />
68
- {#if isFilterable(col)}
73
+ {#if isFilterable(col) && !reorderActive}
69
74
  <ColumnFilter
70
75
  column={col}
71
76
  entries={distinctValues[col.attribute] ?? []}