@greendrake/ui-data 0.1.0

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.
@@ -0,0 +1,1085 @@
1
+ <script lang="ts">
2
+ const DEBOUNCE_MS = 150
3
+ // Inertial scrolling: a released pan keeps scrolling, its velocity decaying
4
+ // exponentially with time constant τ. The velocity floor is both the minimum
5
+ // release velocity that starts a fling and the speed at which one stops; the
6
+ // release velocity is estimated over the trailing window of pan samples.
7
+ const FLING_DECAY_TAU_MS = 325
8
+ const FLING_MIN_VELOCITY_PX_MS = 0.05
9
+ const FLING_SAMPLE_WINDOW_MS = 100
10
+
11
+ // setPointerCapture throws NotFoundError when the pointer is no longer active —
12
+ // released or cancelled between event dispatch and this call, a real race for
13
+ // touch/pen and under synthetic pointer streams. A gesture whose pointer is
14
+ // already gone has nothing to capture, so exactly that error is a no-op;
15
+ // anything else still throws.
16
+ const capturePointer = (event: PointerEvent): void => {
17
+ try {
18
+ ;(event.currentTarget as Element).setPointerCapture(event.pointerId)
19
+ } catch (e) {
20
+ if (!(e instanceof DOMException && e.name === 'NotFoundError')) throw e
21
+ }
22
+ }
23
+ </script>
24
+ <script setup lang="ts" generic="T extends Record<string, unknown>">
25
+ import { ref, computed, watch, onMounted, onUnmounted, type Ref } from 'vue'
26
+ import { useSelection, messages, TOUCH_PAN_SLOP_PX } from '@greendrake/ui'
27
+ import { rowKeyOf, type Props, type DropTarget, type Filters, type LoaderParams, type LoaderResponse, type RowPosition, type VisibleRange, type InfiniteScrollTableExposed } from './InfiniteScrollTable.types'
28
+
29
+ const props = withDefaults(defineProps<Props<T>>(), {
30
+ rowHeight: 45,
31
+ visibleHeight: null,
32
+ filters: () => ({}),
33
+ textFilterKey: 'q',
34
+ localSearchFields: () => ['title'],
35
+ rowKey: 'id',
36
+ selectable: false,
37
+ rowDraggable: false,
38
+ hideHeader: false,
39
+ bufferSize: 1000,
40
+ renderAll: false
41
+ })
42
+
43
+ const emit = defineEmits<{
44
+ 'range-changed': [range: VisibleRange]
45
+ rowactivate: [record: T]
46
+ rowdragstart: [record: T, event: DragEvent]
47
+ rowdrop: [event: DragEvent, target: DropTarget<T> | null]
48
+ }>()
49
+
50
+ // State (`as Ref<…>` on the row-bearing refs: ref()'s UnwrapRef cannot resolve
51
+ // over an unbound generic, so their types are pinned explicitly)
52
+ const fullDataset = ref([]) as Ref<T[]>
53
+ const filteredSortedData = ref([]) as Ref<T[]>
54
+ const bufferStart = ref(0)
55
+ const totalRecords = ref(0)
56
+ const scrollPosition = ref(0)
57
+ const prevScrollPosition = ref(0)
58
+ const loading = ref(false)
59
+ const isDragging = ref(false)
60
+ const debounceTimer = ref<ReturnType<typeof setTimeout> | null>(null)
61
+ const dragStartOffset = ref(0)
62
+ const containerRef = ref<HTMLElement | null>(null)
63
+ const calculatedHeight = ref(600)
64
+ const theadRef = ref<HTMLElement | null>(null)
65
+ const theadHeight = ref(0)
66
+ const lastRequestOffset = ref<number | null>(null)
67
+ const baselineFilters = ref<Filters | null>(null)
68
+ const hasAllRecords = ref(false)
69
+ const colWidths = ref<number[]>([])
70
+ const resizeCol = ref<{ index: number; startX: number; startWidth: number } | null>(null)
71
+ const dropTarget = ref(null) as Ref<DropTarget<T> | null>
72
+
73
+ const keyOf = (record: T): string | number => rowKeyOf(record, props.rowKey)
74
+
75
+ // Selection — useSelection identifies items by a field, so rows are adapted to
76
+ // `{ key }` objects; this also serves function rowKeys (composite keys with no
77
+ // backing field).
78
+ const selectionItems = computed<Record<string, unknown>[]>(() => filteredSortedData.value.map(record => ({ key: keyOf(record) })))
79
+ const selection = props.selectable ? useSelection(selectionItems, 'key') : null
80
+
81
+ function scrollToIndex(index: number): void {
82
+ if (index < 0 || maxScrollOffset.value <= 0) return
83
+ const rowTop = index * props.rowHeight
84
+ const rowBottom = rowTop + props.rowHeight
85
+ let offset = pixelOffset.value
86
+ if (rowTop < offset) {
87
+ offset = rowTop
88
+ } else if (rowBottom > offset + availableBodyHeight.value) {
89
+ offset = rowBottom - availableBodyHeight.value
90
+ } else {
91
+ return
92
+ }
93
+ stopFling()
94
+ scrollPosition.value = Math.max(0, Math.min(1, offset / maxScrollOffset.value))
95
+ }
96
+
97
+ function onRowPointerDown(record: T, e: PointerEvent): void {
98
+ // Touch/pen select on pointerup instead (see onRowPointerUp), so that
99
+ // starting a scroll pan on a row doesn't change the selection.
100
+ if (!selection || e.pointerType !== 'mouse') return
101
+ const id = keyOf(record)
102
+ if (e.shiftKey) {
103
+ selection.rangeTo(id)
104
+ } else if (e.ctrlKey || e.metaKey) {
105
+ selection.toggle(id)
106
+ } else {
107
+ selection.replace(id)
108
+ }
109
+ }
110
+
111
+ // A panning pointer is captured by the wrapper (see onWrapperPointerMove), so
112
+ // its pointerup never reaches the row — this fires only for genuine taps.
113
+ function onRowPointerUp(record: T, e: PointerEvent): void {
114
+ if (!selection || e.pointerType === 'mouse') return
115
+ selection.replace(keyOf(record))
116
+ }
117
+
118
+ function onTableKeyDown(e: KeyboardEvent): void {
119
+ if (!selection) return
120
+ if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
121
+ e.preventDefault()
122
+ selection.selectAll()
123
+ return
124
+ }
125
+ const isUp = e.key === 'ArrowUp'
126
+ const isDown = e.key === 'ArrowDown'
127
+ if (!isUp && !isDown) return
128
+ e.preventDefault()
129
+ const newIndex = selection.moveCursor(isUp ? 'up' : 'down', e.shiftKey)
130
+ scrollToIndex(newIndex)
131
+ }
132
+
133
+ const initColWidths = (): void => {
134
+ if (!theadRef.value) return
135
+ const ths = theadRef.value.querySelectorAll('th')
136
+ colWidths.value = Array.from(ths, th => th.offsetWidth)
137
+ }
138
+
139
+ // Column resize and scroll-handle dragging use pointer capture on the grabbed
140
+ // element, so the drag keeps tracking the pointer outside the element (and the
141
+ // component) without document-level listeners — and works for touch/pen too.
142
+ const onColResizeStart = (event: PointerEvent, index: number): void => {
143
+ event.preventDefault()
144
+ event.stopPropagation()
145
+ capturePointer(event)
146
+ initColWidths()
147
+ resizeCol.value = {
148
+ index,
149
+ startX: event.clientX,
150
+ startWidth: colWidths.value[index]
151
+ }
152
+ }
153
+
154
+ const onColResizeMove = (event: PointerEvent): void => {
155
+ if (!resizeCol.value) return
156
+ const delta = event.clientX - resizeCol.value.startX
157
+ colWidths.value[resizeCol.value.index] = Math.max(30, resizeCol.value.startWidth + delta)
158
+ }
159
+
160
+ const onColResizeEnd = (): void => {
161
+ resizeCol.value = null
162
+ }
163
+
164
+ // Computed values
165
+ const effectiveHeight = computed(() => props.visibleHeight || calculatedHeight.value)
166
+ const availableBodyHeight = computed(() => effectiveHeight.value - theadHeight.value)
167
+ // Whole rows that fully fit the body viewport — the basis for overscan and the
168
+ // "fully visible" range readout.
169
+ const visibleCount = computed(() => Math.max(1, Math.floor(availableBodyHeight.value / props.rowHeight)))
170
+ // Rows actually rendered: the fully-fitting rows plus two — one for a partial row
171
+ // clipped at the top (the body is pixel-scrolled, so the first row is usually cut)
172
+ // and one for a partial row clipped at the bottom. Constant per viewport size, so
173
+ // the row node count stays stable across scrolling (no per-pixel add/remove churn).
174
+ // With `renderAll` (testability affordance) the window instead spans the whole
175
+ // dataset, putting every buffered row in the DOM regardless of viewport height.
176
+ const renderCount = computed(() => (props.renderAll ? Math.max(totalRecords.value, 1) : visibleCount.value + 2))
177
+
178
+ // PIXEL SCROLL MODEL — scrollPosition (0..1) maps to an absolute pixel offset into
179
+ // the full content (totalRecords × rowHeight). Only renderCount rows are in the
180
+ // DOM, starting at firstIndex; the row block is shifted up by rowPixelOffset (the
181
+ // sub-row remainder) so scrolling is continuous instead of snapping to whole rows.
182
+ // At max offset the last row sits flush against the bottom edge.
183
+ const contentHeight = computed(() => totalRecords.value * props.rowHeight)
184
+ const maxScrollOffset = computed(() => Math.max(0, contentHeight.value - availableBodyHeight.value))
185
+ const pixelOffset = computed(() => scrollPosition.value * maxScrollOffset.value)
186
+ const firstIndex = computed(() => Math.floor(pixelOffset.value / props.rowHeight))
187
+ const rowPixelOffset = computed(() => pixelOffset.value - firstIndex.value * props.rowHeight)
188
+
189
+ // The scrollbar spans the body viewport (offset below the header by theadHeight,
190
+ // set inline). The handle is the viewport's fraction of the total content height,
191
+ // positioned by scrollPosition.
192
+ const scrollbarHeight = computed(() => availableBodyHeight.value)
193
+ const scrollHandleHeight = computed(() => {
194
+ if (maxScrollOffset.value <= 0) return 0
195
+ return Math.max(50, scrollbarHeight.value * (availableBodyHeight.value / contentHeight.value))
196
+ })
197
+ const scrollHandleTop = computed(() => scrollPosition.value * (scrollbarHeight.value - scrollHandleHeight.value))
198
+
199
+ const visibleRecords = computed(() => {
200
+ const bufferOffset = firstIndex.value - bufferStart.value
201
+ return filteredSortedData.value.slice(bufferOffset, bufferOffset + renderCount.value)
202
+ })
203
+
204
+ // True while the viewport overlaps records the buffer doesn't hold — i.e. the
205
+ // user is looking at rows that still have to arrive. Drives the spinner and the
206
+ // display-hold below, and flips off by itself the moment the data lands,
207
+ // whatever its source (network fetch, ApiWrapper response cache). This is
208
+ // deliberately DERIVED state: the `loading` ref belongs to the loader/ApiWrapper
209
+ // (true only while a call is in flight) and must not be written here — a cached
210
+ // response never flips it, so a component that pre-set it true would stall.
211
+ // The render window is clamped to the dataset tail: past-the-end positions at
212
+ // the bottom of the list are not a gap.
213
+ const hasVisibleGaps = computed(() => {
214
+ if (hasAllRecords.value || maxScrollOffset.value <= 0) return false
215
+ const bufferOffset = firstIndex.value - bufferStart.value
216
+ if (bufferOffset < 0) return true
217
+ const needed = Math.min(renderCount.value, totalRecords.value - firstIndex.value)
218
+ return bufferOffset + needed > filteredSortedData.value.length
219
+ })
220
+
221
+ // Rows actually painted, plus the sub-row offset they're painted at. Normally
222
+ // these mirror visibleRecords/rowPixelOffset. But a large scroll jump moves the
223
+ // viewport into records that aren't buffered yet (hasVisibleGaps), so
224
+ // visibleRecords briefly slices to empty while the next buffer loads — if
225
+ // painted directly the table would blank out and collapse. Instead the previous
226
+ // non-empty set is held (frozen in place) until the buffer arrives, so the
227
+ // table stays put under the loading spinner. The hold is released for a genuine
228
+ // empty result (filter/sort with nothing pending and no gaps).
229
+ const displayRecords = ref([]) as Ref<T[]>
230
+ const displayRowPixelOffset = ref(0)
231
+ watch(
232
+ [visibleRecords, rowPixelOffset, loading, hasVisibleGaps],
233
+ ([rows, offset, isLoading, gaps]) => {
234
+ if (rows.length > 0) {
235
+ displayRecords.value = rows
236
+ displayRowPixelOffset.value = offset
237
+ } else if (!isLoading && !gaps) {
238
+ displayRecords.value = []
239
+ }
240
+ },
241
+ { immediate: true }
242
+ )
243
+
244
+ const visibleRange = computed(() => {
245
+ if (totalRecords.value === 0) {
246
+ return {
247
+ start: 0,
248
+ end: 0,
249
+ total: 0
250
+ }
251
+ }
252
+ // Records whose rows are FULLY inside the viewport (an edge-clipped partial row
253
+ // doesn't count toward the readout).
254
+ const firstFull = Math.ceil(pixelOffset.value / props.rowHeight)
255
+ const lastFull = Math.floor((pixelOffset.value + availableBodyHeight.value) / props.rowHeight) - 1
256
+ return {
257
+ start: firstFull + 1,
258
+ end: Math.min(lastFull + 1, totalRecords.value),
259
+ total: totalRecords.value
260
+ }
261
+ })
262
+
263
+ // Local filtering/sorting helpers
264
+ const isTextFilterRefinement = (newFilters: Filters, baseline: Filters, textKey: string): boolean => {
265
+ const newText = newFilters[textKey] || ''
266
+ const baseText = baseline[textKey] || ''
267
+ if (!baseText) return true
268
+ return newText.toLowerCase().includes(baseText.toLowerCase())
269
+ }
270
+
271
+ const canOperateLocally = (newFilters: Filters): boolean => {
272
+ // localSearchFields: false = the caller's search semantics cannot be
273
+ // mirrored client-side; text-filter changes always go remote.
274
+ if (props.localSearchFields === false || !hasAllRecords.value || !baselineFilters.value) return false
275
+ const textKey = props.textFilterKey
276
+ const sortKeys = ['sort_by', 'sort_dir']
277
+ for (const key of Object.keys(newFilters)) {
278
+ if (key === textKey || sortKeys.includes(key)) continue
279
+ if (newFilters[key] !== baselineFilters.value[key]) return false
280
+ }
281
+ return isTextFilterRefinement(newFilters, baselineFilters.value, textKey)
282
+ }
283
+
284
+ const applyLocalFilter = (data: T[], textFilter: string | null | undefined, searchFields: string[] | false): T[] => {
285
+ if (!textFilter || searchFields === false) return data
286
+ const lowerFilter = textFilter.toLowerCase()
287
+ return data.filter(record =>
288
+ searchFields.some(field => {
289
+ const value = record[field]
290
+ return value && String(value).toLowerCase().includes(lowerFilter)
291
+ })
292
+ )
293
+ }
294
+
295
+ const getFieldType = (field: string, records: T[]): 'string' | 'number' | 'date' => {
296
+ const col = props.columns.find(c => c.field === field)
297
+ if (col?.sortType) return col.sortType
298
+ const sample = records.find(r => r[field] != null)?.[field]
299
+ if (sample == null) return 'string'
300
+ if (typeof sample === 'number') return 'number'
301
+ if (sample instanceof Date || (typeof sample === 'string' && /^\d{4}-\d{2}-\d{2}/.test(sample))) return 'date'
302
+ return 'string'
303
+ }
304
+
305
+ const compareValues = (a: unknown, b: unknown, type: 'string' | 'number' | 'date'): number => {
306
+ if (a == null && b == null) return 0
307
+ if (a == null) return 1
308
+ if (b == null) return -1
309
+ switch (type) {
310
+ case 'number':
311
+ return (a as number) - (b as number)
312
+ case 'date':
313
+ return new Date(a as string).getTime() - new Date(b as string).getTime()
314
+ default:
315
+ return String(a).localeCompare(String(b), undefined, {
316
+ sensitivity: 'base'
317
+ })
318
+ }
319
+ }
320
+
321
+ const applyLocalSort = (data: T[], sortBy: string | null | undefined, sortDir: string | null | undefined): T[] => {
322
+ if (!sortBy) return data
323
+ const type = getFieldType(sortBy, data)
324
+ const dir = sortDir === 'asc' ? 1 : -1
325
+ return [...data].sort((a, b) => dir * compareValues(a[sortBy], b[sortBy], type))
326
+ }
327
+
328
+ const applyLocalOperations = (resetScroll = true): void => {
329
+ const textFilter = props.filters[props.textFilterKey]
330
+ let result = applyLocalFilter(fullDataset.value, textFilter, props.localSearchFields)
331
+ result = applyLocalSort(result, props.filters.sort_by, props.filters.sort_dir)
332
+ filteredSortedData.value = result
333
+ totalRecords.value = result.length
334
+ if (resetScroll) {
335
+ scrollPosition.value = 0
336
+ bufferStart.value = 0
337
+ }
338
+ }
339
+
340
+ // API functions
341
+ const fetchData = async (offset: number, limit: number, background: boolean): Promise<LoaderResponse<T>> => {
342
+ const params: LoaderParams = {
343
+ offset,
344
+ limit
345
+ }
346
+ Object.entries(props.filters).forEach(([key, value]) => {
347
+ if (value !== null && value !== undefined && value !== '') {
348
+ params[key] = value
349
+ }
350
+ })
351
+ return props.loader(params, loading, background)
352
+ }
353
+
354
+ const loadBuffer = async (targetOffset: number, scrollingDown = true, background = false): Promise<void> => {
355
+ const paddingTotal = Math.max(0, props.bufferSize - renderCount.value)
356
+ const beforeRatio = scrollingDown ? 0.3 : 0.7
357
+ const paddingBefore = Math.round(beforeRatio * paddingTotal)
358
+
359
+ let offset = Math.max(0, targetOffset - paddingBefore)
360
+
361
+ const endNeeded = targetOffset + renderCount.value
362
+ if (totalRecords.value > 0 && endNeeded >= totalRecords.value) {
363
+ offset = Math.max(0, totalRecords.value - props.bufferSize)
364
+ }
365
+
366
+ if (offset === lastRequestOffset.value) {
367
+ return
368
+ }
369
+
370
+ const { data, total } = await fetchData(offset, props.bufferSize, background)
371
+ lastRequestOffset.value = offset
372
+
373
+ if (data.length >= total) {
374
+ fullDataset.value = data
375
+ hasAllRecords.value = true
376
+ baselineFilters.value = { ...props.filters }
377
+ applyLocalOperations()
378
+ } else {
379
+ hasAllRecords.value = false
380
+ filteredSortedData.value = data
381
+ bufferStart.value = offset
382
+ totalRecords.value = total
383
+ }
384
+ }
385
+
386
+ const updateScrollPosition = (newPosition: number): void => {
387
+ const clampedPosition = Math.max(0, Math.min(1, newPosition))
388
+ const scrollingDown = clampedPosition >= prevScrollPosition.value
389
+ prevScrollPosition.value = clampedPosition
390
+ scrollPosition.value = clampedPosition
391
+
392
+ if (hasAllRecords.value) return
393
+
394
+ const targetIndex = firstIndex.value
395
+ const bufferEnd = bufferStart.value + filteredSortedData.value.length
396
+
397
+ // Reload when the viewport has outright gaps, or as prefetch when it nears
398
+ // either edge of the buffered range.
399
+ const distanceFromStart = targetIndex - bufferStart.value
400
+ const distanceFromEnd = bufferEnd - targetIndex
401
+ const needsReload = hasVisibleGaps.value || distanceFromStart < renderCount.value * 0.5 || distanceFromEnd < renderCount.value * 0.5
402
+
403
+ if (!needsReload) return
404
+
405
+ if (debounceTimer.value) {
406
+ clearTimeout(debounceTimer.value)
407
+ }
408
+
409
+ debounceTimer.value = setTimeout(() => {
410
+ debounceTimer.value = null
411
+ loadBuffer(firstIndex.value, scrollingDown)
412
+ }, DEBOUNCE_MS)
413
+ }
414
+
415
+ // Scrollbar interaction
416
+ const scrollbarRef = ref<HTMLElement | null>(null)
417
+
418
+ const handleScrollbarClick = (event: MouseEvent): void => {
419
+ if (event.target === scrollbarRef.value) {
420
+ const rect = scrollbarRef.value!.getBoundingClientRect()
421
+ const clickY = event.clientY - rect.top
422
+ const newPosition = clickY / scrollbarHeight.value
423
+ updateScrollPosition(newPosition)
424
+ }
425
+ }
426
+
427
+ const onHandlePointerDown = (event: PointerEvent): void => {
428
+ event.preventDefault()
429
+ capturePointer(event)
430
+ isDragging.value = true
431
+ const pointerY = event.clientY - scrollbarRef.value!.getBoundingClientRect().top
432
+ dragStartOffset.value = pointerY - scrollHandleTop.value
433
+ }
434
+
435
+ const onHandlePointerMove = (event: PointerEvent): void => {
436
+ if (!isDragging.value) return
437
+
438
+ const pointerY = event.clientY - scrollbarRef.value!.getBoundingClientRect().top
439
+ const handleTop = pointerY - dragStartOffset.value
440
+ const maxTop = scrollbarHeight.value - scrollHandleHeight.value
441
+
442
+ updateScrollPosition(maxTop > 0 ? handleTop / maxTop : 0)
443
+ }
444
+
445
+ const onHandlePointerUp = (): void => {
446
+ isDragging.value = false
447
+ dragStartOffset.value = 0
448
+ }
449
+
450
+ // TOUCH PANNING over the rows — the table has no native scrollable overflow
451
+ // (rows are virtualized), so single-finger pans are translated to scroll
452
+ // offset here. Mouse is excluded: it scrolls via the wheel handler and keeps
453
+ // its native drag semantics (text selection, row drag-and-drop). Gesture state
454
+ // is non-reactive on purpose — nothing renders from it.
455
+ let touchPan: { pointerId: number; startY: number; lastY: number; panned: boolean; samples: { t: number; y: number }[] } | null = null
456
+ // A pan gesture may still produce a trailing click (which would activate the
457
+ // row link under the finger); onWrapperClickCapture swallows it. Also reset on
458
+ // pointerdown because browsers don't fire a click after every gesture.
459
+ let suppressNextClick = false
460
+
461
+ // INERTIAL SCROLLING (fling) — after a pan releases with velocity, scrolling
462
+ // continues on a rAF loop with exponential decay. Plain (non-reactive) state.
463
+ let flingFrame: number | null = null
464
+ let flingVelocity = 0 // scroll-offset px/ms; positive reveals further-down records
465
+ let flingLastTime = 0
466
+
467
+ // Stops an active fling; reports whether one was running so a pointerdown that
468
+ // "catches" the moving content can suppress its trailing click.
469
+ const stopFling = (): boolean => {
470
+ if (flingFrame === null) return false
471
+ cancelAnimationFrame(flingFrame)
472
+ flingFrame = null
473
+ return true
474
+ }
475
+
476
+ const flingStep = (now: number): void => {
477
+ flingFrame = null
478
+ const dt = now - flingLastTime
479
+ flingLastTime = now
480
+ const target = pixelOffset.value + flingVelocity * dt
481
+ const clamped = Math.min(maxScrollOffset.value, Math.max(0, target))
482
+ updateScrollPosition(maxScrollOffset.value > 0 ? clamped / maxScrollOffset.value : 0)
483
+ flingVelocity *= Math.exp(-dt / FLING_DECAY_TAU_MS)
484
+ // Keep going until the velocity floor or a scroll bound is reached.
485
+ if (clamped === target && Math.abs(flingVelocity) > FLING_MIN_VELOCITY_PX_MS) {
486
+ flingFrame = requestAnimationFrame(flingStep)
487
+ }
488
+ }
489
+
490
+ const startFling = (velocity: number): void => {
491
+ flingVelocity = velocity
492
+ flingLastTime = performance.now()
493
+ flingFrame = requestAnimationFrame(flingStep)
494
+ }
495
+
496
+ const onWrapperPointerDown = (event: PointerEvent): void => {
497
+ // A pointer landing during a fling catches the content: the fling stops
498
+ // and the gesture's trailing click must not activate the row under it.
499
+ suppressNextClick = stopFling()
500
+ if (event.pointerType === 'mouse' || maxScrollOffset.value <= 0) return
501
+ // Touches on the scrollbar belong to the handle drag / track tap-to-jump,
502
+ // not to content panning (which moves in the opposite direction).
503
+ if (scrollbarRef.value?.contains(event.target as Node)) return
504
+ touchPan = {
505
+ pointerId: event.pointerId,
506
+ startY: event.clientY,
507
+ lastY: event.clientY,
508
+ panned: false,
509
+ samples: [{ t: event.timeStamp, y: event.clientY }]
510
+ }
511
+ }
512
+
513
+ const onWrapperPointerMove = (event: PointerEvent): void => {
514
+ if (!touchPan || event.pointerId !== touchPan.pointerId || maxScrollOffset.value <= 0) return
515
+ touchPan.samples.push({ t: event.timeStamp, y: event.clientY })
516
+ while (touchPan.samples.length > 1 && event.timeStamp - touchPan.samples[0].t > FLING_SAMPLE_WINDOW_MS) {
517
+ touchPan.samples.shift()
518
+ }
519
+ if (!touchPan.panned) {
520
+ if (Math.abs(event.clientY - touchPan.startY) < TOUCH_PAN_SLOP_PX) return
521
+ touchPan.panned = true
522
+ // Capturing retargets the rest of the gesture (incl. pointerup) to the
523
+ // wrapper, keeping the pan alive outside the table and away from rows.
524
+ capturePointer(event)
525
+ }
526
+ // Content follows the finger: moving up reveals further-down records.
527
+ const newOffset = Math.min(maxScrollOffset.value, Math.max(0, pixelOffset.value - (event.clientY - touchPan.lastY)))
528
+ touchPan.lastY = event.clientY
529
+ updateScrollPosition(newOffset / maxScrollOffset.value)
530
+ }
531
+
532
+ const onWrapperPointerEnd = (event: PointerEvent): void => {
533
+ if (!touchPan || event.pointerId !== touchPan.pointerId) return
534
+ suppressNextClick = touchPan.panned
535
+ if (touchPan.panned && event.type !== 'pointercancel') {
536
+ // Release velocity over the trailing sample window. A finger held
537
+ // still before lifting leaves no fresh samples — no fling.
538
+ const samples = touchPan.samples.filter(s => event.timeStamp - s.t <= FLING_SAMPLE_WINDOW_MS)
539
+ const dt = samples.length > 1 ? samples[samples.length - 1].t - samples[0].t : 0
540
+ if (dt > 0) {
541
+ // Content moves opposite to the finger, hence the negation.
542
+ const velocity = -(samples[samples.length - 1].y - samples[0].y) / dt
543
+ if (Math.abs(velocity) > FLING_MIN_VELOCITY_PX_MS) {
544
+ startFling(velocity)
545
+ }
546
+ }
547
+ }
548
+ touchPan = null
549
+ }
550
+
551
+ const onWrapperClickCapture = (event: MouseEvent): void => {
552
+ if (!suppressNextClick) return
553
+ suppressNextClick = false
554
+ event.preventDefault()
555
+ event.stopPropagation()
556
+ }
557
+
558
+ const handleWheel = (event: WheelEvent): void => {
559
+ if (scrollHandleHeight.value === 0) return
560
+ event.preventDefault()
561
+ stopFling()
562
+ if (maxScrollOffset.value <= 0) return
563
+
564
+ // Use the wheel's own pixel delta for natural, smooth scrolling; convert
565
+ // line- and page-mode deltas (deltaMode 1/2) to pixels.
566
+ const unit = event.deltaMode === 1 ? props.rowHeight : event.deltaMode === 2 ? availableBodyHeight.value : 1
567
+ const newOffset = Math.min(maxScrollOffset.value, Math.max(0, pixelOffset.value + event.deltaY * unit))
568
+ updateScrollPosition(newOffset / maxScrollOffset.value)
569
+ }
570
+
571
+ // Height calculation
572
+ const updateHeight = (): void => {
573
+ if (containerRef.value && !props.visibleHeight) {
574
+ const rect = containerRef.value.getBoundingClientRect()
575
+ calculatedHeight.value = rect.height
576
+ }
577
+ if (theadRef.value) {
578
+ theadHeight.value = theadRef.value.offsetHeight
579
+ }
580
+ }
581
+
582
+ let resizeObserver: ResizeObserver | null = null
583
+
584
+ // Lifecycle
585
+ onMounted(() => {
586
+ updateHeight()
587
+ loadBuffer(0)
588
+
589
+ if (containerRef.value && !props.visibleHeight) {
590
+ resizeObserver = new ResizeObserver(() => {
591
+ updateHeight()
592
+ })
593
+ resizeObserver.observe(containerRef.value)
594
+ }
595
+ })
596
+
597
+ onUnmounted(() => {
598
+ stopFling()
599
+ if (debounceTimer.value) {
600
+ clearTimeout(debounceTimer.value)
601
+ }
602
+ if (resizeObserver) {
603
+ resizeObserver.disconnect()
604
+ }
605
+ })
606
+
607
+ // Watch filters
608
+ watch(
609
+ () => props.filters,
610
+ newFilters => {
611
+ stopFling()
612
+ if (canOperateLocally(newFilters)) {
613
+ applyLocalOperations()
614
+ } else {
615
+ hasAllRecords.value = false
616
+ baselineFilters.value = null
617
+ scrollPosition.value = 0
618
+ lastRequestOffset.value = null
619
+ loadBuffer(0)
620
+ }
621
+ },
622
+ { deep: true }
623
+ )
624
+
625
+ // Emit range changes
626
+ watch(
627
+ visibleRange,
628
+ newRange => {
629
+ emit('range-changed', newRange)
630
+ },
631
+ { immediate: true }
632
+ )
633
+
634
+ // Expose methods for parent components
635
+ // `background` marks a re-read nobody asked for (a live binding answering a
636
+ // push or a reconnect): it reaches the loader, which decides what a quiet
637
+ // request looks like. The promise is returned so such a binding can tell when
638
+ // the read is done and what it did — a foreground press ignores it.
639
+ const refresh = (background = false): Promise<void> => {
640
+ hasAllRecords.value = false
641
+ baselineFilters.value = null
642
+ lastRequestOffset.value = null
643
+ return loadBuffer(firstIndex.value, true, background)
644
+ }
645
+
646
+ /**
647
+ * Surgically update a single row by key.
648
+ * More efficient than refresh() when WebSocket pushes individual updates.
649
+ */
650
+ const patchRow = (keyValue: string | number, data: Partial<T>): boolean => {
651
+ // Update in fullDataset if we have all records
652
+ if (hasAllRecords.value) {
653
+ const fullIndex = fullDataset.value.findIndex(r => keyOf(r) === keyValue)
654
+ if (fullIndex !== -1) {
655
+ fullDataset.value[fullIndex] = {
656
+ ...fullDataset.value[fullIndex],
657
+ ...data
658
+ }
659
+ }
660
+ }
661
+
662
+ // Update in filteredSortedData (the working display array)
663
+ const filteredIndex = filteredSortedData.value.findIndex(r => keyOf(r) === keyValue)
664
+ if (filteredIndex !== -1) {
665
+ filteredSortedData.value[filteredIndex] = {
666
+ ...filteredSortedData.value[filteredIndex],
667
+ ...data
668
+ }
669
+ return true
670
+ }
671
+
672
+ return false
673
+ }
674
+
675
+ /**
676
+ * Add a new row to the dataset.
677
+ * Position 'start' adds at beginning (useful for newest-first sort).
678
+ * Position 'end' adds at end.
679
+ * Default is 'start' for typical createdAt desc sorting.
680
+ */
681
+ const addRow = (data: T, position: RowPosition = 'start'): void => {
682
+ const insertInto = (arr: T[]) => {
683
+ if (position === 'start') {
684
+ arr.unshift(data)
685
+ } else if (position === 'end') {
686
+ arr.push(data)
687
+ } else {
688
+ const refKey = 'after' in position ? position.after : position.before
689
+ const idx = arr.findIndex(r => keyOf(r) === refKey)
690
+ if (idx === -1) {
691
+ arr.push(data)
692
+ } else {
693
+ arr.splice('after' in position ? idx + 1 : idx, 0, data)
694
+ }
695
+ }
696
+ }
697
+ if (hasAllRecords.value) {
698
+ insertInto(fullDataset.value)
699
+ applyLocalOperations()
700
+ } else {
701
+ insertInto(filteredSortedData.value)
702
+ totalRecords.value++
703
+ }
704
+ }
705
+
706
+ /**
707
+ * Remove rows from the dataset by key(s).
708
+ */
709
+ const removeRows = (keyValues: (string | number)[]): void => {
710
+ const toRemove = new Set(keyValues)
711
+
712
+ if (hasAllRecords.value) {
713
+ fullDataset.value = fullDataset.value.filter(r => !toRemove.has(keyOf(r)))
714
+ applyLocalOperations()
715
+ } else {
716
+ const before = filteredSortedData.value.length
717
+ filteredSortedData.value = filteredSortedData.value.filter(r => !toRemove.has(keyOf(r)))
718
+ totalRecords.value -= before - filteredSortedData.value.length
719
+ }
720
+ }
721
+
722
+ const removeRow = (keyValue: string | number): void => removeRows([keyValue])
723
+
724
+ /**
725
+ * Re-apply current filter/sort to the displayed data without resetting scroll.
726
+ * Useful after patchRow changes values in the current sort column.
727
+ */
728
+ const resort = (): void => {
729
+ if (!hasAllRecords.value) return
730
+ applyLocalOperations(false)
731
+ }
732
+
733
+ // Drag-and-drop target tracking
734
+ const onTableDragOver = (e: DragEvent): void => {
735
+ if (!props.rowDraggable) return
736
+ e.preventDefault()
737
+ const wrapper = e.currentTarget as HTMLElement
738
+ const rect = wrapper.getBoundingClientRect()
739
+ // Pointer Y in the painted block's own coordinates: the block is shifted up by
740
+ // displayRowPixelOffset, so add it back to map the pointer to a displayRecords index.
741
+ const blockY = e.clientY - rect.top - theadHeight.value + displayRowPixelOffset.value
742
+ const rowIndex = Math.floor(blockY / props.rowHeight)
743
+ const record = displayRecords.value[rowIndex]
744
+ if (record) {
745
+ const rowOffset = blockY - rowIndex * props.rowHeight
746
+ dropTarget.value = { record, position: rowOffset < props.rowHeight / 2 ? 'before' : 'after' }
747
+ } else {
748
+ dropTarget.value = null
749
+ }
750
+ }
751
+
752
+ const onTableDrop = (e: DragEvent): void => {
753
+ if (!props.rowDraggable) return
754
+ emit('rowdrop', e, dropTarget.value)
755
+ dropTarget.value = null
756
+ }
757
+
758
+ const onTableDragLeave = (e: DragEvent): void => {
759
+ if (!props.rowDraggable) return
760
+ const wrapper = e.currentTarget as HTMLElement
761
+ if (!wrapper.contains(e.relatedTarget as Node)) {
762
+ dropTarget.value = null
763
+ }
764
+ }
765
+
766
+ const getRow = (keyValue: string | number): T | undefined => {
767
+ return filteredSortedData.value.find(r => keyOf(r) === keyValue) ?? (hasAllRecords.value ? fullDataset.value.find(r => keyOf(r) === keyValue) : undefined)
768
+ }
769
+
770
+ // Whether changing the text filter to `value` would be served from the in-memory
771
+ // dataset rather than a remote fetch — same predicate the filter watcher uses to
772
+ // route the change. Lets a consumer (e.g. its SearchField) debounce a purely local
773
+ // filter more eagerly than one that hits the network.
774
+ const willFilterLocally = (value: string | undefined): boolean => canOperateLocally({ ...props.filters, [props.textFilterKey]: value })
775
+
776
+ const exposed: InfiniteScrollTableExposed<T> = {
777
+ refresh,
778
+ patchRow,
779
+ addRow,
780
+ removeRow,
781
+ removeRows,
782
+ resort,
783
+ getRow,
784
+ willFilterLocally,
785
+ selected: selection?.selected
786
+ }
787
+ defineExpose(exposed)
788
+ </script>
789
+
790
+ <template>
791
+ <div ref="containerRef" class="infinite-scroll-table" :class="{ spinner: loading || hasVisibleGaps, 'col-resizing': resizeCol }">
792
+ <div class="table-wrapper" :tabindex="selectable ? 0 : undefined" :style="{ touchAction: scrollHandleHeight > 0 ? 'pinch-zoom' : undefined }" @wheel="handleWheel" @keydown="onTableKeyDown" @pointerdown="onWrapperPointerDown" @pointermove="onWrapperPointerMove" @pointerup="onWrapperPointerEnd" @pointercancel="onWrapperPointerEnd" @click.capture="onWrapperClickCapture" @dragover="onTableDragOver" @drop="onTableDrop" @dragleave="onTableDragLeave">
793
+ <table class="data-table">
794
+ <thead v-if="!hideHeader" ref="theadRef">
795
+ <tr>
796
+ <th v-for="(column, i) in columns" :key="column.field" :class="[column.className, { sortable: column.sortable }]" :style="colWidths[i] != null ? { width: colWidths[i] + 'px' } : undefined" @click="column.onHeaderClick">
797
+ {{ column.label }}
798
+ <span v-if="column.sortIcon" class="sort-icon">{{ column.sortIcon }}</span>
799
+ <div class="col-resize-handle" @pointerdown="onColResizeStart($event, i)" @pointermove="onColResizeMove" @pointerup="onColResizeEnd" @pointercancel="onColResizeEnd" />
800
+ </th>
801
+ </tr>
802
+ </thead>
803
+ <tbody :style="{ transform: `translateY(${-displayRowPixelOffset}px)` }">
804
+ <tr v-if="!loading && totalRecords === 0">
805
+ <td :colspan="columns.length" class="empty-cell">
806
+ <slot name="empty">{{ messages.nothingFound }}</slot>
807
+ </td>
808
+ </tr>
809
+ <template v-else>
810
+ <tr v-for="record in displayRecords" :key="keyOf(record)" :style="{ height: `${rowHeight}px` }" :class="{ 'clickable-row': onRowClick, 'selected-row': selection?.selected.has(keyOf(record)), 'drop-before': dropTarget?.record === record && dropTarget.position === 'before', 'drop-after': dropTarget?.record === record && dropTarget.position === 'after' }" :draggable="rowDraggable || undefined" @click="onRowClick?.(record)" @pointerdown="onRowPointerDown(record, $event)" @pointerup="onRowPointerUp(record, $event)" @dblclick="emit('rowactivate', record)" @dragstart="rowDraggable && emit('rowdragstart', record, $event)">
811
+ <td v-for="(column, i) in columns" :key="column.field" :class="[column.className, column.cellClass?.(record[column.field], record), { clickable: column.onCellClick }]" :style="{ height: `${rowHeight}px`, width: colWidths[i] != null ? colWidths[i] + 'px' : undefined }" @click="column.onCellClick?.(record[column.field], record)">
812
+ <div :class="['cell-content', { 'cell-wrap': column.wrap }]" :style="{ height: `${rowHeight}px`, lineHeight: column.wrap ? undefined : `${rowHeight}px` }">
813
+ <component :is="column.component" v-if="column.component" v-bind="column.componentProps?.(record)" />
814
+ <template v-else>
815
+ {{ column.formatter ? column.formatter(record[column.field], record) : record[column.field] }}
816
+ </template>
817
+ </div>
818
+ </td>
819
+ </tr>
820
+ </template>
821
+ </tbody>
822
+ </table>
823
+
824
+ <div v-if="scrollHandleHeight > 0" ref="scrollbarRef" class="custom-scrollbar" :style="{ height: `${scrollbarHeight}px`, marginTop: `${theadHeight}px` }" @click="handleScrollbarClick">
825
+ <div
826
+ class="scroll-handle"
827
+ :class="{ dragging: isDragging }"
828
+ :style="{
829
+ height: `${scrollHandleHeight}px`,
830
+ top: `${scrollHandleTop}px`
831
+ }"
832
+ @pointerdown="onHandlePointerDown"
833
+ @pointermove="onHandlePointerMove"
834
+ @pointerup="onHandlePointerUp"
835
+ @pointercancel="onHandlePointerUp"
836
+ />
837
+ </div>
838
+ </div>
839
+ </div>
840
+ </template>
841
+
842
+ <!--
843
+ FLEX HEIGHT CONSTRAINT — READ BEFORE MODIFYING STYLES
844
+
845
+ This component uses a ResizeObserver to measure its own container height and
846
+ derive the rendered row count from it. If the container's height is driven by
847
+ its content (i.e. by the rendered rows), a feedback loop occurs:
848
+
849
+ more height → more rows rendered → taller content → ResizeObserver fires
850
+ → even more height → … → all records rendered, scrollbar disappears
851
+
852
+ To prevent this, the flex layout must ensure that heights flow TOP-DOWN
853
+ (from the viewport/parent allocation) and never BOTTOM-UP (from table
854
+ content). The critical rules:
855
+
856
+ 1. `.infinite-scroll-table` uses `flex: 1; min-height: 0` so it fills the
857
+ parent's allocated space without growing beyond it.
858
+
859
+ 2. `.table-wrapper` uses `flex: 1 1 0px` — the absolute `0px` flex-basis is
860
+ essential. A percentage basis (`0%`) resolves relative to the parent's
861
+ height; when ancestor heights aren't definite (common in deeply nested
862
+ flex layouts), `0%` can fall back to content-based sizing, re-enabling
863
+ the feedback loop. The absolute `0px` always starts at zero regardless
864
+ of content.
865
+
866
+ 3. `.data-table` has `display: block; overflow: visible`. Its content height
867
+ must NOT inflate the wrapper. This is safe only because rules 1–2 above
868
+ prevent the wrapper from growing based on content.
869
+
870
+ If you change any of these flex/overflow properties, verify at viewport
871
+ heights where all records would fit if unconstrained (e.g. 100 records at
872
+ 28px each = 2800px content vs. a 1400px viewport). The scrollbar must remain
873
+ visible and the container height must stay stable after initial render.
874
+ -->
875
+ <style lang="scss" scoped>
876
+ @use '@greendrake/scss-kit' as *;
877
+ .infinite-scroll-table {
878
+ position: relative;
879
+ overflow: hidden;
880
+ flex: 1;
881
+ min-height: 0;
882
+ display: flex;
883
+ flex-direction: column;
884
+
885
+ &.spinner {
886
+ &::before {
887
+ width: 60px;
888
+ height: 60px;
889
+ margin-top: -30px;
890
+ margin-left: -30px;
891
+ }
892
+ }
893
+
894
+ &.col-resizing {
895
+ user-select: none;
896
+ * {
897
+ cursor: col-resize !important;
898
+ }
899
+ }
900
+ }
901
+
902
+ .table-wrapper {
903
+ position: relative;
904
+ display: flex;
905
+ flex: 1 1 0px;
906
+ min-height: 0;
907
+ overflow: hidden;
908
+ padding-bottom: 1px;
909
+ outline: none;
910
+
911
+ &[tabindex] {
912
+ user-select: none;
913
+ }
914
+ }
915
+
916
+ .data-table {
917
+ width: 100%;
918
+ border-collapse: collapse;
919
+ display: block;
920
+ overflow: visible;
921
+
922
+ thead {
923
+ display: block;
924
+ background: var(--form-background-hover);
925
+ // Paint above the body: the pixel-scrolled row block shifts up behind the
926
+ // header, and the header's opaque background must cover that clipped sliver.
927
+ position: relative;
928
+ z-index: 1;
929
+
930
+ tr {
931
+ display: table;
932
+ width: 100%;
933
+ table-layout: fixed;
934
+ }
935
+
936
+ th {
937
+ position: relative;
938
+ padding: var(--cell-padding, var(--space-2) var(--space-3));
939
+ text-align: start;
940
+ font-weight: 600;
941
+ font-size: var(--font-md);
942
+ white-space: nowrap;
943
+ overflow: hidden;
944
+ text-overflow: ellipsis;
945
+ background: var(--form-background-hover);
946
+
947
+ &.sortable {
948
+ cursor: default;
949
+ user-select: none;
950
+
951
+ &:hover {
952
+ background: var(--form-background);
953
+ }
954
+ }
955
+
956
+ .sort-icon {
957
+ margin-inline-start: var(--space-1);
958
+ font-size: var(--font-xs);
959
+ opacity: 0.7;
960
+ }
961
+
962
+ .col-resize-handle {
963
+ position: absolute;
964
+ inset-inline-end: 0;
965
+ top: 0;
966
+ bottom: 0;
967
+ width: 5px;
968
+ cursor: col-resize;
969
+ z-index: z('raised');
970
+ border-inline-end: 1px solid var(--border-color-light);
971
+ // The drag is handled via pointer events; without this the
972
+ // browser claims touch drags for panning and cancels them.
973
+ touch-action: none;
974
+
975
+ &:hover {
976
+ border-inline-end-color: var(--border-color-hover);
977
+ }
978
+ }
979
+ }
980
+ }
981
+
982
+ tbody {
983
+ display: block;
984
+ overflow: visible;
985
+ padding-bottom: 2px;
986
+
987
+ tr {
988
+ display: table;
989
+ width: 100%;
990
+ table-layout: fixed;
991
+ border-bottom: var(--row-border-bottom, 0);
992
+
993
+ &:last-child {
994
+ border-bottom: none;
995
+ }
996
+
997
+ &.clickable-row {
998
+ cursor: default;
999
+ }
1000
+
1001
+ &.selected-row {
1002
+ background: var(--row-select-color, rgba(0, 123, 255, 0.12));
1003
+ &:hover {
1004
+ background: var(--row-select-color-hover, rgba(0, 123, 255, 0.18));
1005
+ }
1006
+ }
1007
+
1008
+ &.drop-before {
1009
+ box-shadow: inset 0 2px 0 0 var(--accent-color);
1010
+ }
1011
+
1012
+ &.drop-after {
1013
+ box-shadow: inset 0 -2px 0 0 var(--accent-color);
1014
+ }
1015
+
1016
+ td {
1017
+ font-size: var(--font-md);
1018
+ padding: var(--cell-padding, 0);
1019
+ overflow: hidden;
1020
+
1021
+ .cell-content {
1022
+ overflow: hidden;
1023
+ text-overflow: ellipsis;
1024
+ white-space: nowrap;
1025
+
1026
+ &.cell-wrap {
1027
+ white-space: pre-line;
1028
+ text-overflow: clip;
1029
+ display: grid;
1030
+ align-content: center;
1031
+ }
1032
+ }
1033
+
1034
+ &.clickable {
1035
+ cursor: default;
1036
+
1037
+ &:hover {
1038
+ background: var(--form-background-hover);
1039
+ }
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ .loading-cell,
1045
+ .empty-cell {
1046
+ text-align: center;
1047
+ padding: 40px;
1048
+ font-size: var(--font-md);
1049
+ color: var(--font-color);
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ .custom-scrollbar {
1055
+ position: relative;
1056
+ width: 16px;
1057
+ // Height + top offset are set inline to match the rendered rows region (see
1058
+ // scrollbarHeight); flex-start keeps it from stretching to the full wrapper.
1059
+ align-self: flex-start;
1060
+ cursor: default;
1061
+ flex-shrink: 0;
1062
+
1063
+ .scroll-handle {
1064
+ position: absolute;
1065
+ width: 100%;
1066
+ // The drag is handled via pointer events; without this the browser
1067
+ // claims touch drags for panning and cancels them.
1068
+ touch-action: none;
1069
+ background: #888;
1070
+ border-top-right-radius: var(--border-radius);
1071
+ border-bottom-right-radius: var(--border-radius);
1072
+ cursor: grab;
1073
+ transition: background 0.2s;
1074
+
1075
+ &:hover {
1076
+ background: #666;
1077
+ }
1078
+
1079
+ &.dragging {
1080
+ background: #555;
1081
+ cursor: grabbing;
1082
+ }
1083
+ }
1084
+ }
1085
+ </style>