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.
- package/LICENSE +21 -21
- package/README.md +1355 -1342
- package/dist/components/Avatar.svelte +31 -31
- package/dist/components/IconRenderer.svelte +73 -22
- package/dist/components/IconRenderer.svelte.d.ts +2 -0
- package/dist/components/Modal.svelte +75 -75
- package/dist/components/common/Header.svelte +40 -40
- package/dist/components/crud/EmbeddedField.svelte +175 -175
- package/dist/components/crud/Field.svelte +469 -376
- package/dist/components/crud/GenericCRUD.svelte +426 -426
- package/dist/components/crud/SearchInput.svelte +53 -53
- package/dist/components/crud/columns/Avatar.svelte +15 -15
- package/dist/components/crud/columns/Icon.svelte +8 -8
- package/dist/components/crud/views/Create.svelte +232 -232
- package/dist/components/crud/views/Read.svelte +124 -124
- package/dist/components/crud/views/Update.svelte +208 -208
- package/dist/components/crud/views/list/List.svelte +291 -291
- package/dist/components/crud/views/list/Modals.svelte +56 -56
- package/dist/components/crud/views/list/Table.svelte +176 -176
- package/dist/components/crud/views/list/Toolbar.svelte +341 -341
- package/dist/components/form/Button.svelte +27 -27
- package/dist/components/form/Label.svelte +37 -37
- package/dist/components/form/MultiSelect.svelte +248 -248
- package/dist/components/form/PasswordInput.svelte +68 -68
- package/dist/components/form/Required.svelte +1 -1
- package/dist/components/form/Select.svelte +209 -209
- package/dist/components/form/Tree.svelte +62 -62
- package/dist/components/form/TreeNode.svelte +66 -66
- package/dist/components/navigation/Breadcrumbs.svelte +111 -111
- package/dist/components/table/ColumnFilter.svelte +168 -168
- package/dist/components/table/PaginatedTable.svelte +536 -536
- package/dist/components/table/Paginator.svelte +113 -113
- package/dist/components/table/SortHeader.svelte +43 -43
- package/dist/components/table/TableBody.svelte +150 -150
- package/dist/components/table/TableHeader.svelte +88 -88
- package/dist/i18n/en.js +1 -0
- package/dist/i18n/es.js +1 -0
- package/dist/i18n/types.d.ts +1 -0
- package/dist/icons/context.d.ts +5 -0
- package/dist/icons/context.js +10 -0
- package/dist/icons/defaults/Clear.svelte +6 -6
- package/dist/icons/defaults/Create.svelte +6 -6
- package/dist/icons/defaults/Delete.svelte +6 -6
- package/dist/icons/defaults/Download.svelte +7 -7
- package/dist/icons/defaults/Edit.svelte +7 -7
- package/dist/icons/defaults/Filter.svelte +6 -6
- package/dist/icons/defaults/FilterActive.svelte +6 -6
- package/dist/icons/defaults/Folder.svelte +6 -6
- package/dist/icons/defaults/Grip.svelte +7 -7
- package/dist/icons/defaults/Home.svelte +6 -6
- package/dist/icons/defaults/PasswordHide.svelte +9 -9
- package/dist/icons/defaults/PasswordShow.svelte +7 -7
- package/dist/icons/defaults/SortAsc.svelte +6 -6
- package/dist/icons/defaults/SortDesc.svelte +6 -6
- package/dist/icons/defaults/SortNone.svelte +6 -6
- package/dist/icons/defaults/View.svelte +7 -7
- package/dist/icons/sets/bootstrap.js +1 -4
- package/dist/icons/types.d.ts +0 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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>
|