@pdanpdan/virtual-scroll 0.4.0 → 0.5.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/README.md +246 -297
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +873 -257
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2209 -1109
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -2
- package/package.json +5 -1
- package/src/components/VirtualScroll.test.ts +1886 -326
- package/src/components/VirtualScroll.vue +813 -340
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1506 -228
- package/src/composables/useVirtualScroll.ts +789 -373
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +239 -0
- package/src/index.ts +2 -0
- package/src/types.ts +333 -52
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/scroll.test.ts +133 -107
- package/src/utils/scroll.ts +12 -5
- package/src/utils/virtual-scroll-logic.test.ts +653 -320
- package/src/utils/virtual-scroll-logic.ts +685 -389
|
@@ -1,213 +1,32 @@
|
|
|
1
1
|
<script setup lang="ts" generic="T">
|
|
2
|
+
/**
|
|
3
|
+
* A high-performance virtual scrolling component for Vue 3.
|
|
4
|
+
* Supports large lists and grids by only rendering visible items and using coordinate scaling.
|
|
5
|
+
* Features include sticky headers/footers, RTL support, custom scrollbars, and scroll restoration.
|
|
6
|
+
*/
|
|
2
7
|
import type {
|
|
3
8
|
RenderedItem,
|
|
4
9
|
ScrollAlignment,
|
|
5
|
-
|
|
10
|
+
ScrollbarSlotProps,
|
|
6
11
|
ScrollDetails,
|
|
12
|
+
ScrollToIndexOptions,
|
|
13
|
+
VirtualScrollbarProps,
|
|
14
|
+
VirtualScrollComponentProps,
|
|
7
15
|
VirtualScrollProps,
|
|
8
16
|
} from '../types';
|
|
9
17
|
import type { VNodeChild } from 'vue';
|
|
10
18
|
|
|
11
|
-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
19
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useId, watch } from 'vue';
|
|
12
20
|
|
|
13
21
|
import {
|
|
14
|
-
DEFAULT_ITEM_SIZE,
|
|
15
22
|
useVirtualScroll,
|
|
16
23
|
} from '../composables/useVirtualScroll';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Array of items to be virtualized.
|
|
23
|
-
* Required.
|
|
24
|
-
*/
|
|
25
|
-
items: T[];
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Fixed size of each item (in pixels) or a function that returns the size of an item.
|
|
29
|
-
* Pass 0, null or undefined for dynamic size detection via ResizeObserver.
|
|
30
|
-
* @default 40
|
|
31
|
-
*/
|
|
32
|
-
itemSize?: number | ((item: T, index: number) => number) | null;
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Direction of the scroll.
|
|
36
|
-
* - 'vertical': Standard vertical list.
|
|
37
|
-
* - 'horizontal': Standard horizontal list.
|
|
38
|
-
* - 'both': Grid mode virtualizing both rows and columns.
|
|
39
|
-
* @default 'vertical'
|
|
40
|
-
*/
|
|
41
|
-
direction?: 'vertical' | 'horizontal' | 'both';
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Number of items to render before the visible viewport.
|
|
45
|
-
* Useful for smoother scrolling and keyboard navigation.
|
|
46
|
-
* @default 5
|
|
47
|
-
*/
|
|
48
|
-
bufferBefore?: number;
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Number of items to render after the visible viewport.
|
|
52
|
-
* @default 5
|
|
53
|
-
*/
|
|
54
|
-
bufferAfter?: number;
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* The scrollable container element or window.
|
|
58
|
-
* If not provided, the host element (root of VirtualScroll) is used.
|
|
59
|
-
* @default hostRef
|
|
60
|
-
*/
|
|
61
|
-
container?: HTMLElement | Window | null;
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Range of items to render during Server-Side Rendering.
|
|
65
|
-
* When provided, these items will be rendered in-flow before hydration.
|
|
66
|
-
* @see SSRRange
|
|
67
|
-
*/
|
|
68
|
-
ssrRange?: {
|
|
69
|
-
/** First row index to render. */
|
|
70
|
-
start: number;
|
|
71
|
-
/** Last row index to render (exclusive). */
|
|
72
|
-
end: number;
|
|
73
|
-
/** First column index to render (for grid mode). */
|
|
74
|
-
colStart?: number;
|
|
75
|
-
/** Last column index to render (exclusive, for grid mode). */
|
|
76
|
-
colEnd?: number;
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Number of columns for bidirectional (grid) scroll.
|
|
81
|
-
* Only applicable when direction="both".
|
|
82
|
-
* @default 0
|
|
83
|
-
*/
|
|
84
|
-
columnCount?: number;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Fixed width of columns (in pixels), an array of widths, or a function for column widths.
|
|
88
|
-
* Pass 0, null or undefined for dynamic width detection via ResizeObserver.
|
|
89
|
-
* Only applicable when direction="both".
|
|
90
|
-
* @default 100
|
|
91
|
-
*/
|
|
92
|
-
columnWidth?: number | number[] | ((index: number) => number) | null;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* The HTML tag to use for the root container.
|
|
96
|
-
* @default 'div'
|
|
97
|
-
*/
|
|
98
|
-
containerTag?: string;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* The HTML tag to use for the items wrapper.
|
|
102
|
-
* Useful for <table> integration (e.g. 'tbody').
|
|
103
|
-
* @default 'div'
|
|
104
|
-
*/
|
|
105
|
-
wrapperTag?: string;
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The HTML tag to use for each item.
|
|
109
|
-
* Useful for <table> integration (e.g. 'tr').
|
|
110
|
-
* @default 'div'
|
|
111
|
-
*/
|
|
112
|
-
itemTag?: string;
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Additional padding at the start of the scroll container (top or left).
|
|
116
|
-
* Can be a number (applied to current direction) or an object with x/y.
|
|
117
|
-
* @default 0
|
|
118
|
-
*/
|
|
119
|
-
scrollPaddingStart?: number | { x?: number; y?: number; };
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Additional padding at the end of the scroll container (bottom or right).
|
|
123
|
-
* @default 0
|
|
124
|
-
*/
|
|
125
|
-
scrollPaddingEnd?: number | { x?: number; y?: number; };
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Whether the content in the 'header' slot is sticky.
|
|
129
|
-
* If true, the header size is measured and accounted for in scroll padding.
|
|
130
|
-
* @default false
|
|
131
|
-
*/
|
|
132
|
-
stickyHeader?: boolean;
|
|
24
|
+
import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
|
|
25
|
+
import { getPaddingX, getPaddingY } from '../utils/scroll';
|
|
26
|
+
import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
|
|
27
|
+
import VirtualScrollbar from './VirtualScrollbar.vue';
|
|
133
28
|
|
|
134
|
-
|
|
135
|
-
* Whether the content in the 'footer' slot is sticky.
|
|
136
|
-
* @default false
|
|
137
|
-
*/
|
|
138
|
-
stickyFooter?: boolean;
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Gap between items in pixels (vertical gap in vertical/grid mode, horizontal gap in horizontal mode).
|
|
142
|
-
* @default 0
|
|
143
|
-
*/
|
|
144
|
-
gap?: number;
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Gap between columns in pixels. Only applicable when direction="both" or "horizontal".
|
|
148
|
-
* @default 0
|
|
149
|
-
*/
|
|
150
|
-
columnGap?: number;
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Indices of items that should stick to the top/start of the viewport.
|
|
154
|
-
* Supports iOS-style pushing effect where the next sticky item pushes the previous one.
|
|
155
|
-
* @default []
|
|
156
|
-
*/
|
|
157
|
-
stickyIndices?: number[];
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Distance from the end of the scrollable area (in pixels) to trigger the 'load' event.
|
|
161
|
-
* @default 200
|
|
162
|
-
*/
|
|
163
|
-
loadDistance?: number;
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Whether items are currently being loaded.
|
|
167
|
-
* Prevents multiple 'load' events from triggering and shows the 'loading' slot.
|
|
168
|
-
* @default false
|
|
169
|
-
*/
|
|
170
|
-
loading?: boolean;
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Whether to automatically restore and maintain scroll position when items are prepended to the list.
|
|
174
|
-
* Perfect for chat applications.
|
|
175
|
-
* @default false
|
|
176
|
-
*/
|
|
177
|
-
restoreScrollOnPrepend?: boolean;
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Initial scroll index to jump to immediately after mount.
|
|
181
|
-
*/
|
|
182
|
-
initialScrollIndex?: number;
|
|
183
|
-
|
|
184
|
-
/**
|
|
185
|
-
* Alignment for the initial scroll index.
|
|
186
|
-
* @default 'start'
|
|
187
|
-
* @see ScrollAlignment
|
|
188
|
-
*/
|
|
189
|
-
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Default size for items before they are measured by ResizeObserver.
|
|
193
|
-
* Only used when itemSize is dynamic.
|
|
194
|
-
* @default 40
|
|
195
|
-
*/
|
|
196
|
-
defaultItemSize?: number;
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Default width for columns before they are measured by ResizeObserver.
|
|
200
|
-
* Only used when columnWidth is dynamic.
|
|
201
|
-
* @default 100
|
|
202
|
-
*/
|
|
203
|
-
defaultColumnWidth?: number;
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Whether to show debug information (visible offsets and indices) over items.
|
|
207
|
-
* @default false
|
|
208
|
-
*/
|
|
209
|
-
debug?: boolean;
|
|
210
|
-
}
|
|
29
|
+
export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
|
|
211
30
|
|
|
212
31
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
213
32
|
direction: 'vertical',
|
|
@@ -228,6 +47,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
228
47
|
loading: false,
|
|
229
48
|
restoreScrollOnPrepend: false,
|
|
230
49
|
debug: false,
|
|
50
|
+
virtualScrollbar: false,
|
|
231
51
|
});
|
|
232
52
|
|
|
233
53
|
const emit = defineEmits<{
|
|
@@ -270,6 +90,10 @@ const slots = defineSlots<{
|
|
|
270
90
|
* Useful for setting consistent widths in grid mode.
|
|
271
91
|
*/
|
|
272
92
|
getColumnWidth: (index: number) => number;
|
|
93
|
+
/** Vertical gap between items. */
|
|
94
|
+
gap: number;
|
|
95
|
+
/** Horizontal gap between columns. */
|
|
96
|
+
columnGap: number;
|
|
273
97
|
/** Whether this item is configured to be sticky via `stickyIndices`. */
|
|
274
98
|
isSticky?: boolean | undefined;
|
|
275
99
|
/** Whether this item is currently in a sticky state (stuck at the top/start). */
|
|
@@ -287,6 +111,12 @@ const slots = defineSlots<{
|
|
|
287
111
|
* Can be made sticky using the `stickyFooter` prop.
|
|
288
112
|
*/
|
|
289
113
|
footer?: (props: Record<string, never>) => VNodeChild;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Scoped slot for rendering custom scrollbars.
|
|
117
|
+
* If provided, the default VirtualScrollbar is not rendered.
|
|
118
|
+
*/
|
|
119
|
+
scrollbar?: (props: ScrollbarSlotProps) => VNodeChild;
|
|
290
120
|
}>();
|
|
291
121
|
|
|
292
122
|
const hostRef = ref<HTMLElement | null>(null);
|
|
@@ -295,60 +125,66 @@ const headerRef = ref<HTMLElement | null>(null);
|
|
|
295
125
|
const footerRef = ref<HTMLElement | null>(null);
|
|
296
126
|
const itemRefs = new Map<number, HTMLElement>();
|
|
297
127
|
|
|
128
|
+
const instanceId = useId();
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Unique ID for the scrollable container.
|
|
132
|
+
* Used for accessibility (aria-controls) and to target the element in DOM.
|
|
133
|
+
*/
|
|
134
|
+
const containerId = computed(() => `vs-container-${ instanceId }`);
|
|
135
|
+
|
|
298
136
|
const measuredPaddingStart = ref(0);
|
|
299
137
|
const measuredPaddingEnd = ref(0);
|
|
300
138
|
|
|
139
|
+
const effectiveContainer = computed(() => (props.container === undefined ? hostRef.value : props.container));
|
|
140
|
+
|
|
301
141
|
const isHeaderFooterInsideContainer = computed(() => {
|
|
302
|
-
const container =
|
|
303
|
-
? hostRef.value
|
|
304
|
-
: props.container;
|
|
142
|
+
const container = effectiveContainer.value;
|
|
305
143
|
|
|
306
144
|
return container === hostRef.value
|
|
307
145
|
|| (typeof window !== 'undefined' && (container === window || container === null));
|
|
308
146
|
});
|
|
309
147
|
|
|
310
148
|
const virtualScrollProps = computed(() => {
|
|
311
|
-
const pStart = props.scrollPaddingStart;
|
|
312
|
-
const pEnd = props.scrollPaddingEnd;
|
|
313
|
-
|
|
314
149
|
/* Trigger re-evaluation on items array mutations */
|
|
315
150
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
316
151
|
props.items.length;
|
|
317
152
|
|
|
318
|
-
const startX = typeof pStart === 'object'
|
|
319
|
-
? (pStart.x || 0)
|
|
320
|
-
: ((props.direction === 'horizontal' || props.direction === 'both') ? (pStart || 0) : 0);
|
|
321
|
-
const startY = typeof pStart === 'object'
|
|
322
|
-
? (pStart.y || 0)
|
|
323
|
-
: ((props.direction === 'vertical' || props.direction === 'both') ? (pStart || 0) : 0);
|
|
324
|
-
|
|
325
|
-
const endX = typeof pEnd === 'object'
|
|
326
|
-
? (pEnd.x || 0)
|
|
327
|
-
: ((props.direction === 'horizontal' || props.direction === 'both') ? (pEnd || 0) : 0);
|
|
328
|
-
const endY = typeof pEnd === 'object'
|
|
329
|
-
? (pEnd.y || 0)
|
|
330
|
-
: ((props.direction === 'vertical' || props.direction === 'both') ? (pEnd || 0) : 0);
|
|
331
|
-
|
|
332
153
|
return {
|
|
333
154
|
items: props.items,
|
|
334
155
|
itemSize: props.itemSize,
|
|
335
156
|
direction: props.direction,
|
|
336
157
|
bufferBefore: props.bufferBefore,
|
|
337
158
|
bufferAfter: props.bufferAfter,
|
|
338
|
-
container:
|
|
339
|
-
? hostRef.value
|
|
340
|
-
: props.container,
|
|
159
|
+
container: effectiveContainer.value,
|
|
341
160
|
hostElement: wrapperRef.value,
|
|
161
|
+
hostRef: hostRef.value,
|
|
342
162
|
ssrRange: props.ssrRange,
|
|
343
163
|
columnCount: props.columnCount,
|
|
344
164
|
columnWidth: props.columnWidth,
|
|
345
165
|
scrollPaddingStart: {
|
|
346
|
-
x:
|
|
347
|
-
y:
|
|
166
|
+
x: getPaddingX(props.scrollPaddingStart, props.direction),
|
|
167
|
+
y: getPaddingY(props.scrollPaddingStart, props.direction),
|
|
348
168
|
},
|
|
349
169
|
scrollPaddingEnd: {
|
|
350
|
-
x:
|
|
351
|
-
y:
|
|
170
|
+
x: getPaddingX(props.scrollPaddingEnd, props.direction),
|
|
171
|
+
y: getPaddingY(props.scrollPaddingEnd, props.direction),
|
|
172
|
+
},
|
|
173
|
+
flowPaddingStart: {
|
|
174
|
+
x: 0,
|
|
175
|
+
y: props.stickyHeader ? 0 : measuredPaddingStart.value,
|
|
176
|
+
},
|
|
177
|
+
flowPaddingEnd: {
|
|
178
|
+
x: 0,
|
|
179
|
+
y: props.stickyFooter ? 0 : measuredPaddingEnd.value,
|
|
180
|
+
},
|
|
181
|
+
stickyStart: {
|
|
182
|
+
x: 0,
|
|
183
|
+
y: props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0,
|
|
184
|
+
},
|
|
185
|
+
stickyEnd: {
|
|
186
|
+
x: 0,
|
|
187
|
+
y: props.stickyFooter && isHeaderFooterInsideContainer.value ? measuredPaddingEnd.value : 0,
|
|
352
188
|
},
|
|
353
189
|
gap: props.gap,
|
|
354
190
|
columnGap: props.columnGap,
|
|
@@ -366,26 +202,102 @@ const virtualScrollProps = computed(() => {
|
|
|
366
202
|
|
|
367
203
|
const {
|
|
368
204
|
isHydrated,
|
|
205
|
+
isRtl,
|
|
369
206
|
columnRange,
|
|
370
207
|
renderedItems,
|
|
371
208
|
scrollDetails,
|
|
372
|
-
|
|
373
|
-
|
|
209
|
+
renderedHeight,
|
|
210
|
+
renderedWidth,
|
|
374
211
|
getColumnWidth,
|
|
212
|
+
getRowHeight,
|
|
375
213
|
scrollToIndex,
|
|
376
214
|
scrollToOffset,
|
|
377
215
|
updateHostOffset,
|
|
378
216
|
updateItemSizes,
|
|
217
|
+
updateDirection,
|
|
218
|
+
getItemOffset,
|
|
219
|
+
getRowOffset,
|
|
220
|
+
getColumnOffset,
|
|
221
|
+
getItemSize,
|
|
379
222
|
refresh: coreRefresh,
|
|
380
223
|
stopProgrammaticScroll,
|
|
224
|
+
scaleX,
|
|
225
|
+
scaleY,
|
|
226
|
+
isWindowContainer,
|
|
227
|
+
componentOffset,
|
|
228
|
+
renderedVirtualWidth,
|
|
229
|
+
renderedVirtualHeight,
|
|
381
230
|
} = useVirtualScroll(virtualScrollProps);
|
|
382
231
|
|
|
232
|
+
const useVirtualScrolling = computed(() => scaleX.value !== 1 || scaleY.value !== 1);
|
|
233
|
+
|
|
234
|
+
const showVirtualScrollbars = computed(() => {
|
|
235
|
+
if (isWindowContainer.value) {
|
|
236
|
+
return false;
|
|
237
|
+
}
|
|
238
|
+
return props.virtualScrollbar === true || scaleX.value !== 1 || scaleY.value !== 1;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
function handleVerticalScrollbarScrollToOffset(offset: number) {
|
|
242
|
+
const { displayViewportSize } = scrollDetails.value;
|
|
243
|
+
const scrollableRange = renderedHeight.value - displayViewportSize.height;
|
|
244
|
+
if (offset >= scrollableRange - 0.5) {
|
|
245
|
+
scrollToOffset(null, Number.POSITIVE_INFINITY);
|
|
246
|
+
} else {
|
|
247
|
+
const virtualOffset = displayToVirtual(offset, componentOffset.y, scaleY.value);
|
|
248
|
+
scrollToOffset(null, virtualOffset);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function handleHorizontalScrollbarScrollToOffset(offset: number) {
|
|
253
|
+
const { displayViewportSize } = scrollDetails.value;
|
|
254
|
+
const scrollableRange = renderedWidth.value - displayViewportSize.width;
|
|
255
|
+
if (offset >= scrollableRange - 0.5) {
|
|
256
|
+
scrollToOffset(Number.POSITIVE_INFINITY, null);
|
|
257
|
+
} else {
|
|
258
|
+
const virtualOffset = displayToVirtual(offset, componentOffset.x, scaleX.value);
|
|
259
|
+
scrollToOffset(virtualOffset, null);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const verticalScrollbar = useVirtualScrollbar({
|
|
264
|
+
axis: 'vertical',
|
|
265
|
+
totalSize: renderedHeight,
|
|
266
|
+
position: computed(() => scrollDetails.value.displayScrollOffset.y),
|
|
267
|
+
viewportSize: computed(() => scrollDetails.value.displayViewportSize.height),
|
|
268
|
+
scrollToOffset: handleVerticalScrollbarScrollToOffset,
|
|
269
|
+
containerId,
|
|
270
|
+
isRtl,
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
const horizontalScrollbar = useVirtualScrollbar({
|
|
274
|
+
axis: 'horizontal',
|
|
275
|
+
totalSize: renderedWidth,
|
|
276
|
+
position: computed(() => scrollDetails.value.displayScrollOffset.x),
|
|
277
|
+
viewportSize: computed(() => scrollDetails.value.displayViewportSize.width),
|
|
278
|
+
scrollToOffset: handleHorizontalScrollbarScrollToOffset,
|
|
279
|
+
containerId,
|
|
280
|
+
isRtl,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const slotColumnRange = computed(() => {
|
|
284
|
+
if (props.direction !== 'both') {
|
|
285
|
+
return columnRange.value;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
...columnRange.value,
|
|
289
|
+
padStart: 0,
|
|
290
|
+
padEnd: 0,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
|
|
383
294
|
/**
|
|
384
295
|
* Resets all dynamic measurements and re-initializes from props.
|
|
385
296
|
* Also triggers manual re-measurement of all currently rendered items.
|
|
386
297
|
*/
|
|
387
298
|
function refresh() {
|
|
388
299
|
coreRefresh();
|
|
300
|
+
updateDirection();
|
|
389
301
|
nextTick(() => {
|
|
390
302
|
const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
|
|
391
303
|
|
|
@@ -408,13 +320,15 @@ function refresh() {
|
|
|
408
320
|
|
|
409
321
|
// Watch for scroll details and emit event
|
|
410
322
|
watch(scrollDetails, (details, oldDetails) => {
|
|
411
|
-
if (!isHydrated.value) {
|
|
323
|
+
if (!isHydrated.value || !details) {
|
|
412
324
|
return;
|
|
413
325
|
}
|
|
414
326
|
emit('scroll', details);
|
|
415
327
|
|
|
416
328
|
if (
|
|
417
329
|
!oldDetails
|
|
330
|
+
|| !oldDetails.range
|
|
331
|
+
|| !oldDetails.columnRange
|
|
418
332
|
|| details.range.start !== oldDetails.range.start
|
|
419
333
|
|| details.range.end !== oldDetails.range.end
|
|
420
334
|
|| details.columnRange.start !== oldDetails.columnRange.start
|
|
@@ -433,14 +347,14 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
433
347
|
}
|
|
434
348
|
|
|
435
349
|
// vertical or both
|
|
436
|
-
if (props.direction !== 'horizontal') {
|
|
350
|
+
if (props.direction !== 'horizontal' && details.totalSize) {
|
|
437
351
|
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
|
|
438
352
|
if (remaining <= props.loadDistance) {
|
|
439
353
|
emit('load', 'vertical');
|
|
440
354
|
}
|
|
441
355
|
}
|
|
442
356
|
// horizontal or both
|
|
443
|
-
if (props.direction !== 'vertical') {
|
|
357
|
+
if (props.direction !== 'vertical' && details.totalSize) {
|
|
444
358
|
const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
|
|
445
359
|
if (remaining <= props.loadDistance) {
|
|
446
360
|
emit('load', 'horizontal');
|
|
@@ -449,7 +363,7 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
449
363
|
});
|
|
450
364
|
|
|
451
365
|
watch(isHydrated, (hydrated) => {
|
|
452
|
-
if (hydrated) {
|
|
366
|
+
if (hydrated && scrollDetails.value?.range && scrollDetails.value?.columnRange) {
|
|
453
367
|
emit('visibleRangeChange', {
|
|
454
368
|
start: scrollDetails.value.range.start,
|
|
455
369
|
end: scrollDetails.value.range.end,
|
|
@@ -513,6 +427,8 @@ watch(headerRef, (newEl, oldEl) => {
|
|
|
513
427
|
}
|
|
514
428
|
if (newEl) {
|
|
515
429
|
extraResizeObserver?.observe(newEl);
|
|
430
|
+
} else {
|
|
431
|
+
measuredPaddingStart.value = 0;
|
|
516
432
|
}
|
|
517
433
|
}, { immediate: true });
|
|
518
434
|
|
|
@@ -522,6 +438,8 @@ watch(footerRef, (newEl, oldEl) => {
|
|
|
522
438
|
}
|
|
523
439
|
if (newEl) {
|
|
524
440
|
extraResizeObserver?.observe(newEl);
|
|
441
|
+
} else {
|
|
442
|
+
measuredPaddingEnd.value = 0;
|
|
525
443
|
}
|
|
526
444
|
}, { immediate: true });
|
|
527
445
|
|
|
@@ -548,6 +466,22 @@ watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
|
|
|
548
466
|
}
|
|
549
467
|
});
|
|
550
468
|
|
|
469
|
+
watch([ hostRef, useVirtualScrolling ], ([ host, virtual ], [ oldHost, oldVirtual ]) => {
|
|
470
|
+
const needsUpdate = host !== oldHost || virtual !== oldVirtual;
|
|
471
|
+
if (oldHost && needsUpdate) {
|
|
472
|
+
oldHost.removeEventListener('wheel', handleWheel);
|
|
473
|
+
}
|
|
474
|
+
if (host && needsUpdate) {
|
|
475
|
+
host.addEventListener('wheel', handleWheel, { passive: !virtual });
|
|
476
|
+
}
|
|
477
|
+
}, { immediate: true });
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Callback ref to track and measure item elements.
|
|
481
|
+
*
|
|
482
|
+
* @param el - The element or null if unmounting.
|
|
483
|
+
* @param index - The original index of the item.
|
|
484
|
+
*/
|
|
551
485
|
function setItemRef(el: unknown, index: number) {
|
|
552
486
|
if (el) {
|
|
553
487
|
itemRefs.set(index, el as HTMLElement);
|
|
@@ -568,54 +502,342 @@ function setItemRef(el: unknown, index: number) {
|
|
|
568
502
|
}
|
|
569
503
|
}
|
|
570
504
|
|
|
505
|
+
/**
|
|
506
|
+
* State for inertia scrolling
|
|
507
|
+
*/
|
|
508
|
+
const isPointerScrolling = ref(false);
|
|
509
|
+
let startPointerPos = { x: 0, y: 0 };
|
|
510
|
+
let startScrollOffset = { x: 0, y: 0 };
|
|
511
|
+
let lastPointerPos = { x: 0, y: 0 };
|
|
512
|
+
let lastPointerTime = 0;
|
|
513
|
+
let velocity = { x: 0, y: 0 };
|
|
514
|
+
let inertiaAnimationFrame: number | null = null;
|
|
515
|
+
|
|
516
|
+
// Friction constant (0.9 to 0.98 is usually best)
|
|
517
|
+
const FRICTION = 0.95;
|
|
518
|
+
// Minimum velocity to continue the animation
|
|
519
|
+
const MIN_VELOCITY = 0.1;
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Recursively animates the scroll offset based on velocity and friction.
|
|
523
|
+
*/
|
|
524
|
+
function startInertiaAnimation() {
|
|
525
|
+
const step = () => {
|
|
526
|
+
// Apply friction to the velocity
|
|
527
|
+
velocity.x *= FRICTION;
|
|
528
|
+
velocity.y *= FRICTION;
|
|
529
|
+
|
|
530
|
+
// Calculate the new scroll offset
|
|
531
|
+
const currentX = scrollDetails.value.scrollOffset.x;
|
|
532
|
+
const currentY = scrollDetails.value.scrollOffset.y;
|
|
533
|
+
|
|
534
|
+
// Move the scroll position by the current velocity
|
|
535
|
+
scrollToOffset(
|
|
536
|
+
currentX + velocity.x * 16, // Assuming ~60fps (16ms per frame)
|
|
537
|
+
currentY + velocity.y * 16,
|
|
538
|
+
{ behavior: 'auto' },
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
// Continue animation if we haven't slowed down to a halt
|
|
542
|
+
if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
|
|
543
|
+
inertiaAnimationFrame = requestAnimationFrame(step);
|
|
544
|
+
} else {
|
|
545
|
+
stopInertia();
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
inertiaAnimationFrame = requestAnimationFrame(step);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Stops any ongoing inertia animation
|
|
554
|
+
*/
|
|
555
|
+
function stopInertia() {
|
|
556
|
+
if (inertiaAnimationFrame !== null) {
|
|
557
|
+
cancelAnimationFrame(inertiaAnimationFrame);
|
|
558
|
+
inertiaAnimationFrame = null;
|
|
559
|
+
}
|
|
560
|
+
velocity = { x: 0, y: 0 };
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Handles pointer down events on the container to start emulated scrolling when scaling is active.
|
|
565
|
+
*
|
|
566
|
+
* @param event - The pointer down event.
|
|
567
|
+
*/
|
|
568
|
+
function handlePointerDown(event: PointerEvent) {
|
|
569
|
+
stopProgrammaticScroll();
|
|
570
|
+
stopInertia(); // Stop any existing momentum
|
|
571
|
+
|
|
572
|
+
if (!useVirtualScrolling.value) {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Only handle primary button or touch
|
|
577
|
+
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
isPointerScrolling.value = true;
|
|
582
|
+
startPointerPos = { x: event.clientX, y: event.clientY };
|
|
583
|
+
lastPointerPos = { x: event.clientX, y: event.clientY };
|
|
584
|
+
lastPointerTime = performance.now();
|
|
585
|
+
startScrollOffset = {
|
|
586
|
+
x: scrollDetails.value.scrollOffset.x,
|
|
587
|
+
y: scrollDetails.value.scrollOffset.y,
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Handles pointer move events on the container to perform emulated scrolling.
|
|
595
|
+
*
|
|
596
|
+
* @param event - The pointer move event.
|
|
597
|
+
*/
|
|
598
|
+
function handlePointerMove(event: PointerEvent) {
|
|
599
|
+
if (!isPointerScrolling.value) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const now = performance.now();
|
|
604
|
+
const dt = now - lastPointerTime;
|
|
605
|
+
|
|
606
|
+
if (dt > 0) {
|
|
607
|
+
// Calculate instantaneous velocity (pixels per millisecond)
|
|
608
|
+
const instantVelocityX = (lastPointerPos.x - event.clientX) / dt;
|
|
609
|
+
const instantVelocityY = (lastPointerPos.y - event.clientY) / dt;
|
|
610
|
+
|
|
611
|
+
// Use a moving average for smoother velocity tracking
|
|
612
|
+
velocity.x = velocity.x * 0.2 + instantVelocityX * 0.8;
|
|
613
|
+
velocity.y = velocity.y * 0.2 + instantVelocityY * 0.8;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
lastPointerPos = { x: event.clientX, y: event.clientY };
|
|
617
|
+
lastPointerTime = now;
|
|
618
|
+
|
|
619
|
+
const deltaX = startPointerPos.x - event.clientX;
|
|
620
|
+
const deltaY = startPointerPos.y - event.clientY;
|
|
621
|
+
|
|
622
|
+
requestAnimationFrame(() => {
|
|
623
|
+
scrollToOffset(
|
|
624
|
+
startScrollOffset.x + deltaX,
|
|
625
|
+
startScrollOffset.y + deltaY,
|
|
626
|
+
{ behavior: 'auto' },
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Handles pointer up and cancel events to end emulated scrolling.
|
|
633
|
+
*
|
|
634
|
+
* @param event - The pointer event.
|
|
635
|
+
*/
|
|
636
|
+
function handlePointerUp(event: PointerEvent) {
|
|
637
|
+
if (!isPointerScrolling.value) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
isPointerScrolling.value = false;
|
|
642
|
+
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
643
|
+
|
|
644
|
+
// If the user was moving fast enough, start the inertia loop
|
|
645
|
+
if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
|
|
646
|
+
// avoid unwanted cross-axis drift
|
|
647
|
+
if (Math.abs(velocity.x) > 4 * Math.abs(velocity.y)) {
|
|
648
|
+
velocity.y = 0;
|
|
649
|
+
} else if (Math.abs(velocity.y) > 4 * Math.abs(velocity.x)) {
|
|
650
|
+
velocity.x = 0;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
startInertiaAnimation();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Handles mouse wheel events to support high-precision scrolling for large content or virtual scrollbars.
|
|
659
|
+
*
|
|
660
|
+
* @param event - The wheel event.
|
|
661
|
+
*/
|
|
662
|
+
function handleWheel(event: WheelEvent) {
|
|
663
|
+
const { scrollOffset } = scrollDetails.value;
|
|
664
|
+
stopProgrammaticScroll();
|
|
665
|
+
|
|
666
|
+
if (useVirtualScrolling.value) {
|
|
667
|
+
// Prevent default browser scroll as we are handling it manually
|
|
668
|
+
event.preventDefault();
|
|
669
|
+
|
|
670
|
+
// For large content we manually scroll to keep precision/control
|
|
671
|
+
let deltaX = event.deltaX;
|
|
672
|
+
let deltaY = event.deltaY;
|
|
673
|
+
|
|
674
|
+
if (event.shiftKey && deltaX === 0) {
|
|
675
|
+
deltaX = deltaY;
|
|
676
|
+
deltaY = 0;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const targetX = scrollOffset.x + deltaX;
|
|
680
|
+
const targetY = scrollOffset.y + deltaY;
|
|
681
|
+
|
|
682
|
+
scrollToOffset(targetX, targetY, { behavior: 'auto' });
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Handles keyboard events for navigation (Home, End, Arrows, PageUp/Down).
|
|
688
|
+
*
|
|
689
|
+
* @param event - The keyboard event.
|
|
690
|
+
*/
|
|
571
691
|
function handleKeyDown(event: KeyboardEvent) {
|
|
572
692
|
const { viewportSize, scrollOffset } = scrollDetails.value;
|
|
573
693
|
const isHorizontal = props.direction !== 'vertical';
|
|
574
694
|
const isVertical = props.direction !== 'horizontal';
|
|
575
695
|
|
|
696
|
+
const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
|
|
697
|
+
const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
|
|
698
|
+
|
|
576
699
|
switch (event.key) {
|
|
577
|
-
case 'Home':
|
|
700
|
+
case 'Home': {
|
|
578
701
|
event.preventDefault();
|
|
579
702
|
stopProgrammaticScroll();
|
|
580
|
-
|
|
703
|
+
const distance = Math.max(scrollOffset.x, scrollOffset.y);
|
|
704
|
+
const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
|
|
705
|
+
const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
|
|
706
|
+
|
|
707
|
+
scrollToIndex(0, 0, { behavior, align: 'start' });
|
|
581
708
|
break;
|
|
709
|
+
}
|
|
582
710
|
case 'End': {
|
|
583
711
|
event.preventDefault();
|
|
584
712
|
stopProgrammaticScroll();
|
|
585
713
|
const lastItemIndex = props.items.length - 1;
|
|
586
714
|
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
587
715
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
716
|
+
const { totalSize } = scrollDetails.value;
|
|
717
|
+
const distance = Math.max(
|
|
718
|
+
totalSize.width - scrollOffset.x - viewportSize.width,
|
|
719
|
+
totalSize.height - scrollOffset.y - viewportSize.height,
|
|
720
|
+
);
|
|
721
|
+
const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
|
|
722
|
+
const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
|
|
723
|
+
|
|
724
|
+
if (props.direction === 'both') {
|
|
725
|
+
scrollToIndex(lastItemIndex, lastColIndex, { behavior, align: 'end' });
|
|
594
726
|
} else {
|
|
595
|
-
scrollToIndex(
|
|
727
|
+
scrollToIndex(
|
|
728
|
+
props.direction === 'vertical' ? lastItemIndex : 0,
|
|
729
|
+
props.direction === 'horizontal' ? lastItemIndex : 0,
|
|
730
|
+
{ behavior, align: 'end' },
|
|
731
|
+
);
|
|
596
732
|
}
|
|
597
733
|
break;
|
|
598
734
|
}
|
|
599
|
-
case 'ArrowUp':
|
|
735
|
+
case 'ArrowUp': {
|
|
600
736
|
event.preventDefault();
|
|
601
737
|
stopProgrammaticScroll();
|
|
602
|
-
|
|
738
|
+
if (!isVertical) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const { currentIndex, scrollOffset } = scrollDetails.value;
|
|
743
|
+
const viewportTop = scrollOffset.y + sStart.y + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y;
|
|
744
|
+
const itemPos = getRowOffset(currentIndex);
|
|
745
|
+
|
|
746
|
+
if (itemPos < viewportTop - 1) {
|
|
747
|
+
scrollToIndex(currentIndex, null, { align: 'start' });
|
|
748
|
+
} else if (currentIndex > 0) {
|
|
749
|
+
scrollToIndex(currentIndex - 1, null, { align: 'start' });
|
|
750
|
+
}
|
|
603
751
|
break;
|
|
604
|
-
|
|
752
|
+
}
|
|
753
|
+
case 'ArrowDown': {
|
|
605
754
|
event.preventDefault();
|
|
606
755
|
stopProgrammaticScroll();
|
|
607
|
-
|
|
756
|
+
if (!isVertical) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const { currentEndIndex } = scrollDetails.value;
|
|
761
|
+
const viewportBottom = scrollOffset.y + viewportSize.height - (sEnd.y + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).y);
|
|
762
|
+
const itemBottom = getRowOffset(currentEndIndex) + getRowHeight(currentEndIndex);
|
|
763
|
+
|
|
764
|
+
if (itemBottom > viewportBottom + 1) {
|
|
765
|
+
scrollToIndex(currentEndIndex, null, { align: 'end' });
|
|
766
|
+
} else if (currentEndIndex < props.items.length - 1) {
|
|
767
|
+
scrollToIndex(currentEndIndex + 1, null, { align: 'end' });
|
|
768
|
+
}
|
|
608
769
|
break;
|
|
609
|
-
|
|
770
|
+
}
|
|
771
|
+
case 'ArrowLeft': {
|
|
610
772
|
event.preventDefault();
|
|
611
773
|
stopProgrammaticScroll();
|
|
612
|
-
|
|
774
|
+
if (!isHorizontal) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const { currentColIndex, currentEndColIndex } = scrollDetails.value;
|
|
779
|
+
|
|
780
|
+
if (isRtl.value) {
|
|
781
|
+
// RTL ArrowLeft -> towards logical END (Left)
|
|
782
|
+
const viewportLeft = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
|
|
783
|
+
const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
|
|
784
|
+
|
|
785
|
+
if (colEndPos > viewportLeft + 1) {
|
|
786
|
+
scrollToIndex(null, currentEndColIndex, { align: 'end' });
|
|
787
|
+
} else {
|
|
788
|
+
const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
|
|
789
|
+
if (currentEndColIndex < maxColIdx) {
|
|
790
|
+
scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
} else {
|
|
794
|
+
// LTR ArrowLeft -> towards logical START (Left)
|
|
795
|
+
const viewportLeft = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
|
|
796
|
+
const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
|
|
797
|
+
|
|
798
|
+
if (colStartPos < viewportLeft - 1) {
|
|
799
|
+
scrollToIndex(null, currentColIndex, { align: 'start' });
|
|
800
|
+
} else if (currentColIndex > 0) {
|
|
801
|
+
scrollToIndex(null, currentColIndex - 1, { align: 'start' });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
613
804
|
break;
|
|
614
|
-
|
|
805
|
+
}
|
|
806
|
+
case 'ArrowRight': {
|
|
615
807
|
event.preventDefault();
|
|
616
808
|
stopProgrammaticScroll();
|
|
617
|
-
|
|
809
|
+
if (!isHorizontal) {
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const { currentColIndex, currentEndColIndex } = scrollDetails.value;
|
|
814
|
+
|
|
815
|
+
if (isRtl.value) {
|
|
816
|
+
// RTL ArrowRight -> towards logical START (Right)
|
|
817
|
+
const viewportRight = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
|
|
818
|
+
const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
|
|
819
|
+
|
|
820
|
+
if (colStartPos < viewportRight - 1) {
|
|
821
|
+
scrollToIndex(null, currentColIndex, { align: 'start' });
|
|
822
|
+
} else if (currentColIndex > 0) {
|
|
823
|
+
scrollToIndex(null, currentColIndex - 1, { align: 'start' });
|
|
824
|
+
}
|
|
825
|
+
} else {
|
|
826
|
+
// LTR ArrowRight -> towards logical END (Right)
|
|
827
|
+
const viewportRight = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
|
|
828
|
+
const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
|
|
829
|
+
|
|
830
|
+
if (colEndPos > viewportRight + 1) {
|
|
831
|
+
scrollToIndex(null, currentEndColIndex, { align: 'end' });
|
|
832
|
+
} else {
|
|
833
|
+
const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
|
|
834
|
+
if (currentEndColIndex < maxColIdx) {
|
|
835
|
+
scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
618
839
|
break;
|
|
840
|
+
}
|
|
619
841
|
case 'PageUp':
|
|
620
842
|
event.preventDefault();
|
|
621
843
|
stopProgrammaticScroll();
|
|
@@ -641,30 +863,118 @@ onUnmounted(() => {
|
|
|
641
863
|
extraResizeObserver?.disconnect();
|
|
642
864
|
});
|
|
643
865
|
|
|
644
|
-
const isWindowContainer = computed(() => isWindowLike(props.container));
|
|
645
|
-
|
|
646
866
|
const containerStyle = computed(() => {
|
|
867
|
+
const base: Record<string, string | number | undefined> = {
|
|
868
|
+
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
if (showVirtualScrollbars.value) {
|
|
872
|
+
base.overflow = 'auto';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (useVirtualScrolling.value) {
|
|
876
|
+
base.touchAction = 'none';
|
|
877
|
+
}
|
|
878
|
+
|
|
647
879
|
if (isWindowContainer.value) {
|
|
648
|
-
return
|
|
649
|
-
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
650
|
-
};
|
|
880
|
+
return base;
|
|
651
881
|
}
|
|
652
882
|
|
|
653
883
|
if (props.containerTag === 'table') {
|
|
654
884
|
return {
|
|
885
|
+
...base,
|
|
655
886
|
minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
|
|
656
887
|
};
|
|
657
888
|
}
|
|
658
889
|
|
|
890
|
+
return base;
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
894
|
+
if (props.direction === 'horizontal') {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
|
|
898
|
+
if (renderedHeight.value <= displayViewportSize.height) {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const scrollbarProps: VirtualScrollbarProps = {
|
|
903
|
+
axis: 'vertical',
|
|
904
|
+
totalSize: renderedHeight.value,
|
|
905
|
+
position: displayScrollOffset.y,
|
|
906
|
+
viewportSize: displayViewportSize.height,
|
|
907
|
+
scrollToOffset: handleVerticalScrollbarScrollToOffset,
|
|
908
|
+
containerId: containerId.value,
|
|
909
|
+
isRtl: isRtl.value,
|
|
910
|
+
};
|
|
911
|
+
|
|
659
912
|
return {
|
|
660
|
-
|
|
913
|
+
positionPercent: verticalScrollbar.positionPercent.value,
|
|
914
|
+
viewportPercent: verticalScrollbar.viewportPercent.value,
|
|
915
|
+
thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
|
|
916
|
+
thumbPositionPercent: verticalScrollbar.thumbPositionPercent.value,
|
|
917
|
+
trackProps: verticalScrollbar.trackProps.value,
|
|
918
|
+
thumbProps: verticalScrollbar.thumbProps.value,
|
|
919
|
+
scrollbarProps,
|
|
920
|
+
isDragging: verticalScrollbar.isDragging.value,
|
|
661
921
|
};
|
|
662
922
|
});
|
|
663
923
|
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
924
|
+
const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
925
|
+
if (props.direction === 'vertical') {
|
|
926
|
+
return null;
|
|
927
|
+
}
|
|
928
|
+
const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
|
|
929
|
+
if (renderedWidth.value <= displayViewportSize.width) {
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const scrollbarProps: VirtualScrollbarProps = {
|
|
934
|
+
axis: 'horizontal',
|
|
935
|
+
totalSize: renderedWidth.value,
|
|
936
|
+
position: displayScrollOffset.x,
|
|
937
|
+
viewportSize: displayViewportSize.width,
|
|
938
|
+
scrollToOffset: handleHorizontalScrollbarScrollToOffset,
|
|
939
|
+
containerId: containerId.value,
|
|
940
|
+
isRtl: isRtl.value,
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
return {
|
|
944
|
+
positionPercent: horizontalScrollbar.positionPercent.value,
|
|
945
|
+
viewportPercent: horizontalScrollbar.viewportPercent.value,
|
|
946
|
+
thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
|
|
947
|
+
thumbPositionPercent: horizontalScrollbar.thumbPositionPercent.value,
|
|
948
|
+
trackProps: horizontalScrollbar.trackProps.value,
|
|
949
|
+
thumbProps: horizontalScrollbar.thumbProps.value,
|
|
950
|
+
scrollbarProps,
|
|
951
|
+
isDragging: horizontalScrollbar.isDragging.value,
|
|
952
|
+
};
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const wrapperStyle = computed(() => {
|
|
956
|
+
const isHorizontal = props.direction === 'horizontal';
|
|
957
|
+
const isVertical = props.direction === 'vertical';
|
|
958
|
+
const isBoth = props.direction === 'both';
|
|
959
|
+
|
|
960
|
+
const style: Record<string, string | number | undefined> = {
|
|
961
|
+
inlineSize: isVertical ? '100%' : `${ renderedVirtualWidth.value }px`,
|
|
962
|
+
blockSize: isHorizontal ? '100%' : `${ renderedVirtualHeight.value }px`,
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
if (!isHydrated.value) {
|
|
966
|
+
style.display = 'flex';
|
|
967
|
+
style.flexDirection = isHorizontal ? 'row' : 'column';
|
|
968
|
+
if ((isHorizontal || isBoth) && props.columnGap) {
|
|
969
|
+
style.columnGap = `${ props.columnGap }px`;
|
|
970
|
+
}
|
|
971
|
+
if ((isVertical || isBoth) && props.gap) {
|
|
972
|
+
style.rowGap = `${ props.gap }px`;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
return style;
|
|
977
|
+
});
|
|
668
978
|
|
|
669
979
|
const loadingStyle = computed(() => {
|
|
670
980
|
const isHorizontal = props.direction === 'horizontal';
|
|
@@ -676,12 +986,18 @@ const loadingStyle = computed(() => {
|
|
|
676
986
|
});
|
|
677
987
|
|
|
678
988
|
const spacerStyle = computed(() => ({
|
|
679
|
-
inlineSize: props.direction === 'vertical' ? '1px' : `${
|
|
680
|
-
blockSize: props.direction === 'horizontal' ? '1px' : `${
|
|
989
|
+
inlineSize: props.direction === 'vertical' ? '1px' : `${ renderedVirtualWidth.value }px`,
|
|
990
|
+
blockSize: props.direction === 'horizontal' ? '1px' : `${ renderedVirtualHeight.value }px`,
|
|
681
991
|
}));
|
|
682
992
|
|
|
993
|
+
/**
|
|
994
|
+
* Calculates the final style object for an item, including position and dimensions.
|
|
995
|
+
*
|
|
996
|
+
* @param item - The rendered item state.
|
|
997
|
+
* @returns CSS style object.
|
|
998
|
+
*/
|
|
683
999
|
function getItemStyle(item: RenderedItem<T>) {
|
|
684
|
-
|
|
1000
|
+
const style = calculateItemStyle({
|
|
685
1001
|
containerTag: props.containerTag,
|
|
686
1002
|
direction: props.direction,
|
|
687
1003
|
isHydrated: isHydrated.value,
|
|
@@ -689,7 +1005,17 @@ function getItemStyle(item: RenderedItem<T>) {
|
|
|
689
1005
|
itemSize: props.itemSize,
|
|
690
1006
|
paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
|
|
691
1007
|
paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
|
|
1008
|
+
isRtl: isRtl.value,
|
|
692
1009
|
});
|
|
1010
|
+
|
|
1011
|
+
if (!isHydrated.value && props.direction === 'both') {
|
|
1012
|
+
style.display = 'flex';
|
|
1013
|
+
if (props.columnGap) {
|
|
1014
|
+
style.columnGap = `${ props.columnGap }px`;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
return style;
|
|
693
1019
|
}
|
|
694
1020
|
|
|
695
1021
|
const isDebug = computed(() => props.debug);
|
|
@@ -698,6 +1024,8 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
|
|
|
698
1024
|
const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
|
|
699
1025
|
|
|
700
1026
|
defineExpose({
|
|
1027
|
+
...toRefs(props),
|
|
1028
|
+
|
|
701
1029
|
/**
|
|
702
1030
|
* Detailed information about the current scroll state.
|
|
703
1031
|
* @see ScrollDetails
|
|
@@ -719,6 +1047,41 @@ defineExpose({
|
|
|
719
1047
|
*/
|
|
720
1048
|
getColumnWidth,
|
|
721
1049
|
|
|
1050
|
+
/**
|
|
1051
|
+
* Helper to get the height of a specific row.
|
|
1052
|
+
* @param index - The row index.
|
|
1053
|
+
* @see useVirtualScroll
|
|
1054
|
+
*/
|
|
1055
|
+
getRowHeight,
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* Helper to get the virtual offset of a specific row.
|
|
1059
|
+
* @param index - The row index.
|
|
1060
|
+
* @see useVirtualScroll
|
|
1061
|
+
*/
|
|
1062
|
+
getRowOffset,
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Helper to get the virtual offset of a specific column.
|
|
1066
|
+
* @param index - The column index.
|
|
1067
|
+
* @see useVirtualScroll
|
|
1068
|
+
*/
|
|
1069
|
+
getColumnOffset,
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* Helper to get the virtual offset of a specific item.
|
|
1073
|
+
* @param index - The item index.
|
|
1074
|
+
* @see useVirtualScroll
|
|
1075
|
+
*/
|
|
1076
|
+
getItemOffset,
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Helper to get the size of a specific item along the scroll axis.
|
|
1080
|
+
* @param index - The item index.
|
|
1081
|
+
* @see useVirtualScroll
|
|
1082
|
+
*/
|
|
1083
|
+
getItemSize,
|
|
1084
|
+
|
|
722
1085
|
/**
|
|
723
1086
|
* Programmatically scroll to a specific row and/or column.
|
|
724
1087
|
*
|
|
@@ -751,13 +1114,69 @@ defineExpose({
|
|
|
751
1114
|
* Immediately stops any currently active smooth scroll animation and clears pending corrections.
|
|
752
1115
|
* @see useVirtualScroll
|
|
753
1116
|
*/
|
|
754
|
-
stopProgrammaticScroll
|
|
1117
|
+
stopProgrammaticScroll: () => {
|
|
1118
|
+
stopProgrammaticScroll();
|
|
1119
|
+
stopInertia();
|
|
1120
|
+
},
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Detects the current direction (LTR/RTL) of the scroll container.
|
|
1124
|
+
*/
|
|
1125
|
+
updateDirection,
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Whether the scroll container is in Right-to-Left (RTL) mode.
|
|
1129
|
+
*/
|
|
1130
|
+
isRtl,
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Whether the component has finished its first client-side mount and hydration.
|
|
1134
|
+
*/
|
|
1135
|
+
isHydrated,
|
|
1136
|
+
|
|
1137
|
+
/**
|
|
1138
|
+
* Coordinate scaling factor for X axis.
|
|
1139
|
+
*/
|
|
1140
|
+
scaleX,
|
|
1141
|
+
|
|
1142
|
+
/**
|
|
1143
|
+
* Coordinate scaling factor for Y axis.
|
|
1144
|
+
*/
|
|
1145
|
+
scaleY,
|
|
1146
|
+
|
|
1147
|
+
/**
|
|
1148
|
+
* Physical width of the content in the DOM (clamped to browser limits).
|
|
1149
|
+
*/
|
|
1150
|
+
renderedWidth,
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Physical height of the content in the DOM (clamped to browser limits).
|
|
1154
|
+
*/
|
|
1155
|
+
renderedHeight,
|
|
1156
|
+
|
|
1157
|
+
/**
|
|
1158
|
+
* Absolute offset of the component within its container.
|
|
1159
|
+
*/
|
|
1160
|
+
componentOffset,
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Properties for the vertical scrollbar.
|
|
1164
|
+
* Useful when building custom scrollbar interfaces.
|
|
1165
|
+
*/
|
|
1166
|
+
scrollbarPropsVertical: verticalScrollbarProps,
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Properties for the horizontal scrollbar.
|
|
1170
|
+
* Useful when building custom scrollbar interfaces.
|
|
1171
|
+
*/
|
|
1172
|
+
scrollbarPropsHorizontal: horizontalScrollbarProps,
|
|
755
1173
|
});
|
|
756
1174
|
</script>
|
|
757
1175
|
|
|
758
1176
|
<template>
|
|
759
1177
|
<component
|
|
760
1178
|
:is="containerTag"
|
|
1179
|
+
:id="containerId"
|
|
761
1180
|
ref="hostRef"
|
|
762
1181
|
class="virtual-scroll-container"
|
|
763
1182
|
:class="[
|
|
@@ -766,15 +1185,37 @@ defineExpose({
|
|
|
766
1185
|
'virtual-scroll--hydrated': isHydrated,
|
|
767
1186
|
'virtual-scroll--window': isWindowContainer,
|
|
768
1187
|
'virtual-scroll--table': isTable,
|
|
1188
|
+
'virtual-scroll--hide-scrollbar': showVirtualScrollbars,
|
|
769
1189
|
},
|
|
770
1190
|
]"
|
|
771
1191
|
:style="containerStyle"
|
|
772
1192
|
tabindex="0"
|
|
773
1193
|
@keydown="handleKeyDown"
|
|
774
|
-
@
|
|
775
|
-
@
|
|
776
|
-
@
|
|
1194
|
+
@pointerdown="handlePointerDown"
|
|
1195
|
+
@pointermove="handlePointerMove"
|
|
1196
|
+
@pointerup="handlePointerUp"
|
|
1197
|
+
@pointercancel="handlePointerUp"
|
|
777
1198
|
>
|
|
1199
|
+
<div
|
|
1200
|
+
v-if="showVirtualScrollbars"
|
|
1201
|
+
class="virtual-scroll-scrollbar-container"
|
|
1202
|
+
>
|
|
1203
|
+
<div
|
|
1204
|
+
class="virtual-scroll-scrollbar-viewport"
|
|
1205
|
+
:style="{
|
|
1206
|
+
'inlineSize': `${ scrollDetails.displayViewportSize.width }px`,
|
|
1207
|
+
'blockSize': `${ scrollDetails.displayViewportSize.height }px`,
|
|
1208
|
+
'--vsi-scrollbar-has-cross-gap': direction === 'both' ? 1 : 0,
|
|
1209
|
+
}"
|
|
1210
|
+
>
|
|
1211
|
+
<slot v-if="slots.scrollbar && verticalScrollbarProps" name="scrollbar" v-bind="verticalScrollbarProps" />
|
|
1212
|
+
<VirtualScrollbar v-else-if="verticalScrollbarProps" v-bind="verticalScrollbarProps.scrollbarProps" />
|
|
1213
|
+
|
|
1214
|
+
<slot v-if="slots.scrollbar && horizontalScrollbarProps" name="scrollbar" v-bind="horizontalScrollbarProps" />
|
|
1215
|
+
<VirtualScrollbar v-else-if="horizontalScrollbarProps" v-bind="horizontalScrollbarProps.scrollbarProps" />
|
|
1216
|
+
</div>
|
|
1217
|
+
</div>
|
|
1218
|
+
|
|
778
1219
|
<component
|
|
779
1220
|
:is="headerTag"
|
|
780
1221
|
v-if="slots.header"
|
|
@@ -818,8 +1259,10 @@ defineExpose({
|
|
|
818
1259
|
name="item"
|
|
819
1260
|
:item="renderedItem.item"
|
|
820
1261
|
:index="renderedItem.index"
|
|
821
|
-
:column-range="
|
|
1262
|
+
:column-range="slotColumnRange"
|
|
822
1263
|
:get-column-width="getColumnWidth"
|
|
1264
|
+
:gap="props.gap"
|
|
1265
|
+
:column-gap="props.columnGap"
|
|
823
1266
|
:is-sticky="renderedItem.isSticky"
|
|
824
1267
|
:is-sticky-active="renderedItem.isStickyActive"
|
|
825
1268
|
/>
|
|
@@ -850,109 +1293,139 @@ defineExpose({
|
|
|
850
1293
|
</template>
|
|
851
1294
|
|
|
852
1295
|
<style scoped>
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1296
|
+
@layer components {
|
|
1297
|
+
.virtual-scroll-container {
|
|
1298
|
+
position: relative;
|
|
1299
|
+
block-size: 100%;
|
|
1300
|
+
inline-size: 100%;
|
|
1301
|
+
outline-offset: 1px;
|
|
1302
|
+
|
|
1303
|
+
&:not(.virtual-scroll--window) {
|
|
1304
|
+
overflow: auto;
|
|
1305
|
+
overscroll-behavior: contain;
|
|
1306
|
+
}
|
|
858
1307
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
1308
|
+
&.virtual-scroll--table {
|
|
1309
|
+
display: block;
|
|
1310
|
+
}
|
|
863
1311
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
1312
|
+
&.virtual-scroll--hide-scrollbar {
|
|
1313
|
+
scrollbar-width: none;
|
|
1314
|
+
-ms-overflow-style: none;
|
|
868
1315
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
1316
|
+
&::-webkit-scrollbar {
|
|
1317
|
+
display: none;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
872
1320
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1321
|
+
&.virtual-scroll--horizontal,
|
|
1322
|
+
&.virtual-scroll--both {
|
|
1323
|
+
white-space: nowrap;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
876
1326
|
|
|
877
|
-
|
|
878
|
-
position:
|
|
1327
|
+
.virtual-scroll-scrollbar-container {
|
|
1328
|
+
position: sticky;
|
|
879
1329
|
inset-block-start: 0;
|
|
880
1330
|
inset-inline-start: 0;
|
|
1331
|
+
inline-size: 100%;
|
|
1332
|
+
block-size: 0;
|
|
1333
|
+
z-index: 30;
|
|
1334
|
+
pointer-events: none;
|
|
1335
|
+
overflow: visible;
|
|
881
1336
|
}
|
|
882
|
-
}
|
|
883
1337
|
|
|
884
|
-
.virtual-scroll-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1338
|
+
.virtual-scroll-scrollbar-viewport {
|
|
1339
|
+
position: absolute;
|
|
1340
|
+
inset-block-start: 0;
|
|
1341
|
+
inset-inline-start: 0;
|
|
1342
|
+
pointer-events: none;
|
|
1343
|
+
}
|
|
888
1344
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1345
|
+
.virtual-scroll-wrapper {
|
|
1346
|
+
contain: layout;
|
|
1347
|
+
position: relative;
|
|
892
1348
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1349
|
+
:where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
|
|
1350
|
+
position: absolute;
|
|
1351
|
+
inset-block-start: 0;
|
|
1352
|
+
inset-inline-start: 0;
|
|
896
1353
|
}
|
|
897
1354
|
}
|
|
898
|
-
}
|
|
899
1355
|
|
|
900
|
-
.virtual-scroll-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
background: rgba(0, 0, 0, 0.7);
|
|
905
|
-
color: white;
|
|
906
|
-
font-size: 10px;
|
|
907
|
-
padding: 2px 4px;
|
|
908
|
-
border-radius: 4px;
|
|
909
|
-
pointer-events: none;
|
|
910
|
-
z-index: 100;
|
|
911
|
-
font-family: monospace;
|
|
912
|
-
}
|
|
1356
|
+
.virtual-scroll-item {
|
|
1357
|
+
display: grid;
|
|
1358
|
+
box-sizing: border-box;
|
|
1359
|
+
will-change: transform;
|
|
913
1360
|
|
|
914
|
-
.virtual-scroll
|
|
915
|
-
|
|
916
|
-
|
|
1361
|
+
&:where(.virtual-scroll--debug) {
|
|
1362
|
+
outline: 1px dashed rgba(255, 0, 0, 0.5);
|
|
1363
|
+
background-color: rgba(255, 0, 0, 0.05);
|
|
917
1364
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
}
|
|
1365
|
+
&:where(:hover) {
|
|
1366
|
+
background-color: rgba(255, 0, 0, 0.1);
|
|
1367
|
+
z-index: 100;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
923
1371
|
|
|
924
|
-
.virtual-scroll
|
|
925
|
-
|
|
1372
|
+
.virtual-scroll-debug-info {
|
|
1373
|
+
position: absolute;
|
|
1374
|
+
inset-block-start: 2px;
|
|
1375
|
+
inset-inline-end: 2px;
|
|
1376
|
+
background: rgba(0, 0, 0, 0.7);
|
|
1377
|
+
color: white;
|
|
1378
|
+
font-size: 10px;
|
|
1379
|
+
padding: 2px 4px;
|
|
1380
|
+
border-radius: 4px;
|
|
1381
|
+
pointer-events: none;
|
|
1382
|
+
z-index: 100;
|
|
1383
|
+
font-family: monospace;
|
|
1384
|
+
}
|
|
926
1385
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
inset-inline-start: 0;
|
|
930
|
-
min-inline-size: 100%;
|
|
931
|
-
box-sizing: border-box;
|
|
1386
|
+
.virtual-scroll-spacer {
|
|
1387
|
+
pointer-events: none;
|
|
932
1388
|
}
|
|
933
1389
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
box-sizing: border-box;
|
|
1390
|
+
.virtual-scroll-header,
|
|
1391
|
+
.virtual-scroll-footer {
|
|
1392
|
+
position: relative;
|
|
1393
|
+
z-index: 20;
|
|
939
1394
|
}
|
|
940
1395
|
|
|
941
|
-
|
|
942
|
-
|
|
1396
|
+
.virtual-scroll--sticky {
|
|
1397
|
+
position: sticky;
|
|
1398
|
+
|
|
1399
|
+
&:where(.virtual-scroll-header) {
|
|
1400
|
+
inset-block-start: 0;
|
|
1401
|
+
inset-inline-start: 0;
|
|
1402
|
+
min-inline-size: 100%;
|
|
1403
|
+
box-sizing: border-box;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
&:where(.virtual-scroll-footer) {
|
|
1407
|
+
inset-block-end: 0;
|
|
1408
|
+
inset-inline-start: 0;
|
|
1409
|
+
min-inline-size: 100%;
|
|
1410
|
+
box-sizing: border-box;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
&:where(.virtual-scroll-item) {
|
|
1414
|
+
z-index: 10;
|
|
1415
|
+
}
|
|
943
1416
|
}
|
|
944
|
-
}
|
|
945
1417
|
|
|
946
|
-
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
947
|
-
display: inline-flex;
|
|
948
|
-
min-inline-size: 100%;
|
|
949
|
-
& > :deep(tr) {
|
|
1418
|
+
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
950
1419
|
display: inline-flex;
|
|
951
1420
|
min-inline-size: 100%;
|
|
1421
|
+
& > :deep(tr) {
|
|
1422
|
+
display: inline-flex;
|
|
1423
|
+
min-inline-size: 100%;
|
|
952
1424
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1425
|
+
& > :is(td, th) {
|
|
1426
|
+
display: inline-block;
|
|
1427
|
+
align-items: center;
|
|
1428
|
+
}
|
|
956
1429
|
}
|
|
957
1430
|
}
|
|
958
1431
|
}
|