@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.
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/package.json +48 -0
- package/src/CrudPanel.vue +305 -0
- package/src/DescriptionList.vue +53 -0
- package/src/InfiniteScrollTable.types.ts +105 -0
- package/src/InfiniteScrollTable.vue +1085 -0
- package/src/MasterDetail.vue +191 -0
- package/src/errorMessage.ts +2 -0
- package/src/formatters.ts +25 -0
- package/src/listLoader.ts +31 -0
- package/src/localFilterDebounce.ts +21 -0
- package/src/main.ts +15 -0
- package/src/useAsyncAction.ts +47 -0
- package/src/useDetailLoader.ts +57 -0
- package/src/useRecordEditor.ts +148 -0
- package/src/useSortState.ts +67 -0
|
@@ -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>
|