runeforge 0.0.61 → 0.0.63

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