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