runeforge 0.0.55 → 0.0.57

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