@pdanpdan/virtual-scroll 0.4.0 → 0.6.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 +172 -324
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +836 -376
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1334 -741
- package/dist/index.mjs.map +1 -1
- package/dist/virtual-scroll.css +1 -1
- package/package.json +8 -2
- package/src/components/VirtualScroll.test.ts +1921 -325
- package/src/components/VirtualScroll.vue +829 -386
- 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 +869 -517
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +244 -0
- package/src/index.ts +9 -0
- package/src/types.ts +353 -110
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/scroll.test.ts +181 -101
- package/src/utils/scroll.ts +43 -5
- package/src/utils/virtual-scroll-logic.test.ts +673 -323
- package/src/utils/virtual-scroll-logic.ts +759 -430
|
@@ -1,213 +1,33 @@
|
|
|
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 {
|
|
8
|
+
ItemSlotProps,
|
|
3
9
|
RenderedItem,
|
|
4
10
|
ScrollAlignment,
|
|
5
|
-
|
|
11
|
+
ScrollbarSlotProps,
|
|
6
12
|
ScrollDetails,
|
|
13
|
+
ScrollToIndexOptions,
|
|
14
|
+
VirtualScrollbarProps,
|
|
15
|
+
VirtualScrollComponentProps,
|
|
7
16
|
VirtualScrollProps,
|
|
8
17
|
} from '../types';
|
|
9
18
|
import type { VNodeChild } from 'vue';
|
|
10
19
|
|
|
11
|
-
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
20
|
+
import { computed, nextTick, onMounted, onUnmounted, ref, toRefs, useId, watch } from 'vue';
|
|
12
21
|
|
|
13
22
|
import {
|
|
14
|
-
DEFAULT_ITEM_SIZE,
|
|
15
23
|
useVirtualScroll,
|
|
16
24
|
} 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;
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* Whether the content in the 'footer' slot is sticky.
|
|
136
|
-
* @default false
|
|
137
|
-
*/
|
|
138
|
-
stickyFooter?: boolean;
|
|
25
|
+
import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
|
|
26
|
+
import { getPaddingX, getPaddingY } from '../utils/scroll';
|
|
27
|
+
import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
|
|
28
|
+
import VirtualScrollbar from './VirtualScrollbar.vue';
|
|
139
29
|
|
|
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
|
-
}
|
|
30
|
+
export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
|
|
211
31
|
|
|
212
32
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
213
33
|
direction: 'vertical',
|
|
@@ -228,6 +48,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
228
48
|
loading: false,
|
|
229
49
|
restoreScrollOnPrepend: false,
|
|
230
50
|
debug: false,
|
|
51
|
+
virtualScrollbar: false,
|
|
231
52
|
});
|
|
232
53
|
|
|
233
54
|
const emit = defineEmits<{
|
|
@@ -246,35 +67,7 @@ const slots = defineSlots<{
|
|
|
246
67
|
/**
|
|
247
68
|
* Scoped slot for rendering each individual item.
|
|
248
69
|
*/
|
|
249
|
-
item?: (props:
|
|
250
|
-
/** The original data item from the `items` array. */
|
|
251
|
-
item: T;
|
|
252
|
-
/** The original index of the item in the `items` array. */
|
|
253
|
-
index: number;
|
|
254
|
-
/**
|
|
255
|
-
* Information about the current visible range of columns (for grid mode).
|
|
256
|
-
* @see ColumnRange
|
|
257
|
-
*/
|
|
258
|
-
columnRange: {
|
|
259
|
-
/** Index of the first rendered column. */
|
|
260
|
-
start: number;
|
|
261
|
-
/** Index of the last rendered column (exclusive). */
|
|
262
|
-
end: number;
|
|
263
|
-
/** Pixel offset from the start of the row to the first rendered cell. */
|
|
264
|
-
padStart: number;
|
|
265
|
-
/** Pixel offset from the last rendered cell to the end of the row. */
|
|
266
|
-
padEnd: number;
|
|
267
|
-
};
|
|
268
|
-
/**
|
|
269
|
-
* Helper function to get the width of a specific column.
|
|
270
|
-
* Useful for setting consistent widths in grid mode.
|
|
271
|
-
*/
|
|
272
|
-
getColumnWidth: (index: number) => number;
|
|
273
|
-
/** Whether this item is configured to be sticky via `stickyIndices`. */
|
|
274
|
-
isSticky?: boolean | undefined;
|
|
275
|
-
/** Whether this item is currently in a sticky state (stuck at the top/start). */
|
|
276
|
-
isStickyActive?: boolean | undefined;
|
|
277
|
-
}) => VNodeChild;
|
|
70
|
+
item?: (props: ItemSlotProps<T>) => VNodeChild;
|
|
278
71
|
|
|
279
72
|
/**
|
|
280
73
|
* Content shown at the end of the list when the `loading` prop is true.
|
|
@@ -287,6 +80,12 @@ const slots = defineSlots<{
|
|
|
287
80
|
* Can be made sticky using the `stickyFooter` prop.
|
|
288
81
|
*/
|
|
289
82
|
footer?: (props: Record<string, never>) => VNodeChild;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scoped slot for rendering custom scrollbars.
|
|
86
|
+
* If provided, the default VirtualScrollbar is not rendered.
|
|
87
|
+
*/
|
|
88
|
+
scrollbar?: (props: ScrollbarSlotProps) => VNodeChild;
|
|
290
89
|
}>();
|
|
291
90
|
|
|
292
91
|
const hostRef = ref<HTMLElement | null>(null);
|
|
@@ -295,60 +94,66 @@ const headerRef = ref<HTMLElement | null>(null);
|
|
|
295
94
|
const footerRef = ref<HTMLElement | null>(null);
|
|
296
95
|
const itemRefs = new Map<number, HTMLElement>();
|
|
297
96
|
|
|
97
|
+
const instanceId = useId();
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Unique ID for the scrollable container.
|
|
101
|
+
* Used for accessibility (aria-controls) and to target the element in DOM.
|
|
102
|
+
*/
|
|
103
|
+
const containerId = computed(() => `vs-container-${ instanceId }`);
|
|
104
|
+
|
|
298
105
|
const measuredPaddingStart = ref(0);
|
|
299
106
|
const measuredPaddingEnd = ref(0);
|
|
300
107
|
|
|
108
|
+
const effectiveContainer = computed(() => (props.container === undefined ? hostRef.value : props.container));
|
|
109
|
+
|
|
301
110
|
const isHeaderFooterInsideContainer = computed(() => {
|
|
302
|
-
const container =
|
|
303
|
-
? hostRef.value
|
|
304
|
-
: props.container;
|
|
111
|
+
const container = effectiveContainer.value;
|
|
305
112
|
|
|
306
113
|
return container === hostRef.value
|
|
307
114
|
|| (typeof window !== 'undefined' && (container === window || container === null));
|
|
308
115
|
});
|
|
309
116
|
|
|
310
117
|
const virtualScrollProps = computed(() => {
|
|
311
|
-
const pStart = props.scrollPaddingStart;
|
|
312
|
-
const pEnd = props.scrollPaddingEnd;
|
|
313
|
-
|
|
314
118
|
/* Trigger re-evaluation on items array mutations */
|
|
315
119
|
// eslint-disable-next-line ts/no-unused-expressions
|
|
316
120
|
props.items.length;
|
|
317
121
|
|
|
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
122
|
return {
|
|
333
123
|
items: props.items,
|
|
334
124
|
itemSize: props.itemSize,
|
|
335
125
|
direction: props.direction,
|
|
336
126
|
bufferBefore: props.bufferBefore,
|
|
337
127
|
bufferAfter: props.bufferAfter,
|
|
338
|
-
container:
|
|
339
|
-
? hostRef.value
|
|
340
|
-
: props.container,
|
|
128
|
+
container: effectiveContainer.value,
|
|
341
129
|
hostElement: wrapperRef.value,
|
|
130
|
+
hostRef: hostRef.value,
|
|
342
131
|
ssrRange: props.ssrRange,
|
|
343
132
|
columnCount: props.columnCount,
|
|
344
133
|
columnWidth: props.columnWidth,
|
|
345
134
|
scrollPaddingStart: {
|
|
346
|
-
x:
|
|
347
|
-
y:
|
|
135
|
+
x: getPaddingX(props.scrollPaddingStart, props.direction),
|
|
136
|
+
y: getPaddingY(props.scrollPaddingStart, props.direction),
|
|
348
137
|
},
|
|
349
138
|
scrollPaddingEnd: {
|
|
350
|
-
x:
|
|
351
|
-
y:
|
|
139
|
+
x: getPaddingX(props.scrollPaddingEnd, props.direction),
|
|
140
|
+
y: getPaddingY(props.scrollPaddingEnd, props.direction),
|
|
141
|
+
},
|
|
142
|
+
flowPaddingStart: {
|
|
143
|
+
x: 0,
|
|
144
|
+
y: props.stickyHeader ? 0 : measuredPaddingStart.value,
|
|
145
|
+
},
|
|
146
|
+
flowPaddingEnd: {
|
|
147
|
+
x: 0,
|
|
148
|
+
y: props.stickyFooter ? 0 : measuredPaddingEnd.value,
|
|
149
|
+
},
|
|
150
|
+
stickyStart: {
|
|
151
|
+
x: 0,
|
|
152
|
+
y: props.stickyHeader && isHeaderFooterInsideContainer.value ? measuredPaddingStart.value : 0,
|
|
153
|
+
},
|
|
154
|
+
stickyEnd: {
|
|
155
|
+
x: 0,
|
|
156
|
+
y: props.stickyFooter && isHeaderFooterInsideContainer.value ? measuredPaddingEnd.value : 0,
|
|
352
157
|
},
|
|
353
158
|
gap: props.gap,
|
|
354
159
|
columnGap: props.columnGap,
|
|
@@ -366,26 +171,102 @@ const virtualScrollProps = computed(() => {
|
|
|
366
171
|
|
|
367
172
|
const {
|
|
368
173
|
isHydrated,
|
|
174
|
+
isRtl,
|
|
369
175
|
columnRange,
|
|
370
176
|
renderedItems,
|
|
371
177
|
scrollDetails,
|
|
372
|
-
|
|
373
|
-
|
|
178
|
+
renderedHeight,
|
|
179
|
+
renderedWidth,
|
|
374
180
|
getColumnWidth,
|
|
181
|
+
getRowHeight,
|
|
375
182
|
scrollToIndex,
|
|
376
183
|
scrollToOffset,
|
|
377
184
|
updateHostOffset,
|
|
378
185
|
updateItemSizes,
|
|
186
|
+
updateDirection,
|
|
187
|
+
getItemOffset,
|
|
188
|
+
getRowOffset,
|
|
189
|
+
getColumnOffset,
|
|
190
|
+
getItemSize,
|
|
379
191
|
refresh: coreRefresh,
|
|
380
192
|
stopProgrammaticScroll,
|
|
193
|
+
scaleX,
|
|
194
|
+
scaleY,
|
|
195
|
+
isWindowContainer,
|
|
196
|
+
componentOffset,
|
|
197
|
+
renderedVirtualWidth,
|
|
198
|
+
renderedVirtualHeight,
|
|
381
199
|
} = useVirtualScroll(virtualScrollProps);
|
|
382
200
|
|
|
201
|
+
const useVirtualScrolling = computed(() => scaleX.value !== 1 || scaleY.value !== 1);
|
|
202
|
+
|
|
203
|
+
const showVirtualScrollbars = computed(() => {
|
|
204
|
+
if (isWindowContainer.value) {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
return props.virtualScrollbar === true || scaleX.value !== 1 || scaleY.value !== 1;
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
function handleVerticalScrollbarScrollToOffset(offset: number) {
|
|
211
|
+
const { displayViewportSize } = scrollDetails.value;
|
|
212
|
+
const scrollableRange = renderedHeight.value - displayViewportSize.height;
|
|
213
|
+
if (offset >= scrollableRange - 0.5) {
|
|
214
|
+
scrollToOffset(null, Number.POSITIVE_INFINITY);
|
|
215
|
+
} else {
|
|
216
|
+
const virtualOffset = displayToVirtual(offset, componentOffset.y, scaleY.value);
|
|
217
|
+
scrollToOffset(null, virtualOffset);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function handleHorizontalScrollbarScrollToOffset(offset: number) {
|
|
222
|
+
const { displayViewportSize } = scrollDetails.value;
|
|
223
|
+
const scrollableRange = renderedWidth.value - displayViewportSize.width;
|
|
224
|
+
if (offset >= scrollableRange - 0.5) {
|
|
225
|
+
scrollToOffset(Number.POSITIVE_INFINITY, null);
|
|
226
|
+
} else {
|
|
227
|
+
const virtualOffset = displayToVirtual(offset, componentOffset.x, scaleX.value);
|
|
228
|
+
scrollToOffset(virtualOffset, null);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const verticalScrollbar = useVirtualScrollbar({
|
|
233
|
+
axis: 'vertical',
|
|
234
|
+
totalSize: renderedHeight,
|
|
235
|
+
position: computed(() => scrollDetails.value.displayScrollOffset.y),
|
|
236
|
+
viewportSize: computed(() => scrollDetails.value.displayViewportSize.height),
|
|
237
|
+
scrollToOffset: handleVerticalScrollbarScrollToOffset,
|
|
238
|
+
containerId,
|
|
239
|
+
isRtl,
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const horizontalScrollbar = useVirtualScrollbar({
|
|
243
|
+
axis: 'horizontal',
|
|
244
|
+
totalSize: renderedWidth,
|
|
245
|
+
position: computed(() => scrollDetails.value.displayScrollOffset.x),
|
|
246
|
+
viewportSize: computed(() => scrollDetails.value.displayViewportSize.width),
|
|
247
|
+
scrollToOffset: handleHorizontalScrollbarScrollToOffset,
|
|
248
|
+
containerId,
|
|
249
|
+
isRtl,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const slotColumnRange = computed(() => {
|
|
253
|
+
if (props.direction !== 'both') {
|
|
254
|
+
return columnRange.value;
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
...columnRange.value,
|
|
258
|
+
padStart: 0,
|
|
259
|
+
padEnd: 0,
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
|
|
383
263
|
/**
|
|
384
264
|
* Resets all dynamic measurements and re-initializes from props.
|
|
385
265
|
* Also triggers manual re-measurement of all currently rendered items.
|
|
386
266
|
*/
|
|
387
267
|
function refresh() {
|
|
388
268
|
coreRefresh();
|
|
269
|
+
updateDirection();
|
|
389
270
|
nextTick(() => {
|
|
390
271
|
const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
|
|
391
272
|
|
|
@@ -408,13 +289,15 @@ function refresh() {
|
|
|
408
289
|
|
|
409
290
|
// Watch for scroll details and emit event
|
|
410
291
|
watch(scrollDetails, (details, oldDetails) => {
|
|
411
|
-
if (!isHydrated.value) {
|
|
292
|
+
if (!isHydrated.value || !details) {
|
|
412
293
|
return;
|
|
413
294
|
}
|
|
414
295
|
emit('scroll', details);
|
|
415
296
|
|
|
416
297
|
if (
|
|
417
298
|
!oldDetails
|
|
299
|
+
|| !oldDetails.range
|
|
300
|
+
|| !oldDetails.columnRange
|
|
418
301
|
|| details.range.start !== oldDetails.range.start
|
|
419
302
|
|| details.range.end !== oldDetails.range.end
|
|
420
303
|
|| details.columnRange.start !== oldDetails.columnRange.start
|
|
@@ -433,14 +316,14 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
433
316
|
}
|
|
434
317
|
|
|
435
318
|
// vertical or both
|
|
436
|
-
if (props.direction !== 'horizontal') {
|
|
319
|
+
if (props.direction !== 'horizontal' && details.totalSize) {
|
|
437
320
|
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
|
|
438
321
|
if (remaining <= props.loadDistance) {
|
|
439
322
|
emit('load', 'vertical');
|
|
440
323
|
}
|
|
441
324
|
}
|
|
442
325
|
// horizontal or both
|
|
443
|
-
if (props.direction !== 'vertical') {
|
|
326
|
+
if (props.direction !== 'vertical' && details.totalSize) {
|
|
444
327
|
const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
|
|
445
328
|
if (remaining <= props.loadDistance) {
|
|
446
329
|
emit('load', 'horizontal');
|
|
@@ -449,7 +332,7 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
449
332
|
});
|
|
450
333
|
|
|
451
334
|
watch(isHydrated, (hydrated) => {
|
|
452
|
-
if (hydrated) {
|
|
335
|
+
if (hydrated && scrollDetails.value?.range && scrollDetails.value?.columnRange) {
|
|
453
336
|
emit('visibleRangeChange', {
|
|
454
337
|
start: scrollDetails.value.range.start,
|
|
455
338
|
end: scrollDetails.value.range.end,
|
|
@@ -507,23 +390,21 @@ const extraResizeObserver = typeof window === 'undefined'
|
|
|
507
390
|
updateHostOffset();
|
|
508
391
|
});
|
|
509
392
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
}
|
|
393
|
+
function watchExtraRef(refEl: typeof headerRef, measuredValue: typeof measuredPaddingStart) {
|
|
394
|
+
watch(refEl, (newEl, oldEl) => {
|
|
395
|
+
if (oldEl) {
|
|
396
|
+
extraResizeObserver?.unobserve(oldEl);
|
|
397
|
+
}
|
|
398
|
+
if (newEl) {
|
|
399
|
+
extraResizeObserver?.observe(newEl);
|
|
400
|
+
} else {
|
|
401
|
+
measuredValue.value = 0;
|
|
402
|
+
}
|
|
403
|
+
}, { immediate: true });
|
|
404
|
+
}
|
|
518
405
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
extraResizeObserver?.unobserve(oldEl);
|
|
522
|
-
}
|
|
523
|
-
if (newEl) {
|
|
524
|
-
extraResizeObserver?.observe(newEl);
|
|
525
|
-
}
|
|
526
|
-
}, { immediate: true });
|
|
406
|
+
watchExtraRef(headerRef, measuredPaddingStart);
|
|
407
|
+
watchExtraRef(footerRef, measuredPaddingEnd);
|
|
527
408
|
|
|
528
409
|
onMounted(() => {
|
|
529
410
|
if (hostRef.value) {
|
|
@@ -548,6 +429,22 @@ watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
|
|
|
548
429
|
}
|
|
549
430
|
});
|
|
550
431
|
|
|
432
|
+
watch([ hostRef, useVirtualScrolling ], ([ host, virtual ], [ oldHost, oldVirtual ]) => {
|
|
433
|
+
const needsUpdate = host !== oldHost || virtual !== oldVirtual;
|
|
434
|
+
if (oldHost && needsUpdate) {
|
|
435
|
+
oldHost.removeEventListener('wheel', handleWheel);
|
|
436
|
+
}
|
|
437
|
+
if (host && needsUpdate) {
|
|
438
|
+
host.addEventListener('wheel', handleWheel, { passive: !virtual });
|
|
439
|
+
}
|
|
440
|
+
}, { immediate: true });
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Callback ref to track and measure item elements.
|
|
444
|
+
*
|
|
445
|
+
* @param el - The element or null if unmounting.
|
|
446
|
+
* @param index - The original index of the item.
|
|
447
|
+
*/
|
|
551
448
|
function setItemRef(el: unknown, index: number) {
|
|
552
449
|
if (el) {
|
|
553
450
|
itemRefs.set(index, el as HTMLElement);
|
|
@@ -568,54 +465,342 @@ function setItemRef(el: unknown, index: number) {
|
|
|
568
465
|
}
|
|
569
466
|
}
|
|
570
467
|
|
|
468
|
+
/**
|
|
469
|
+
* State for inertia scrolling
|
|
470
|
+
*/
|
|
471
|
+
const isPointerScrolling = ref(false);
|
|
472
|
+
let startPointerPos = { x: 0, y: 0 };
|
|
473
|
+
let startScrollOffset = { x: 0, y: 0 };
|
|
474
|
+
let lastPointerPos = { x: 0, y: 0 };
|
|
475
|
+
let lastPointerTime = 0;
|
|
476
|
+
let velocity = { x: 0, y: 0 };
|
|
477
|
+
let inertiaAnimationFrame: number | null = null;
|
|
478
|
+
|
|
479
|
+
// Friction constant (0.9 to 0.98 is usually best)
|
|
480
|
+
const FRICTION = 0.95;
|
|
481
|
+
// Minimum velocity to continue the animation
|
|
482
|
+
const MIN_VELOCITY = 0.1;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Recursively animates the scroll offset based on velocity and friction.
|
|
486
|
+
*/
|
|
487
|
+
function startInertiaAnimation() {
|
|
488
|
+
const step = () => {
|
|
489
|
+
// Apply friction to the velocity
|
|
490
|
+
velocity.x *= FRICTION;
|
|
491
|
+
velocity.y *= FRICTION;
|
|
492
|
+
|
|
493
|
+
// Calculate the new scroll offset
|
|
494
|
+
const currentX = scrollDetails.value.scrollOffset.x;
|
|
495
|
+
const currentY = scrollDetails.value.scrollOffset.y;
|
|
496
|
+
|
|
497
|
+
// Move the scroll position by the current velocity
|
|
498
|
+
scrollToOffset(
|
|
499
|
+
currentX + velocity.x * 16, // Assuming ~60fps (16ms per frame)
|
|
500
|
+
currentY + velocity.y * 16,
|
|
501
|
+
{ behavior: 'auto' },
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
// Continue animation if we haven't slowed down to a halt
|
|
505
|
+
if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
|
|
506
|
+
inertiaAnimationFrame = requestAnimationFrame(step);
|
|
507
|
+
} else {
|
|
508
|
+
stopInertia();
|
|
509
|
+
}
|
|
510
|
+
};
|
|
511
|
+
|
|
512
|
+
inertiaAnimationFrame = requestAnimationFrame(step);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Stops any ongoing inertia animation
|
|
517
|
+
*/
|
|
518
|
+
function stopInertia() {
|
|
519
|
+
if (inertiaAnimationFrame !== null) {
|
|
520
|
+
cancelAnimationFrame(inertiaAnimationFrame);
|
|
521
|
+
inertiaAnimationFrame = null;
|
|
522
|
+
}
|
|
523
|
+
velocity = { x: 0, y: 0 };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Handles pointer down events on the container to start emulated scrolling when scaling is active.
|
|
528
|
+
*
|
|
529
|
+
* @param event - The pointer down event.
|
|
530
|
+
*/
|
|
531
|
+
function handlePointerDown(event: PointerEvent) {
|
|
532
|
+
stopProgrammaticScroll();
|
|
533
|
+
stopInertia(); // Stop any existing momentum
|
|
534
|
+
|
|
535
|
+
if (!useVirtualScrolling.value) {
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Only handle primary button or touch
|
|
540
|
+
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
isPointerScrolling.value = true;
|
|
545
|
+
startPointerPos = { x: event.clientX, y: event.clientY };
|
|
546
|
+
lastPointerPos = { x: event.clientX, y: event.clientY };
|
|
547
|
+
lastPointerTime = performance.now();
|
|
548
|
+
startScrollOffset = {
|
|
549
|
+
x: scrollDetails.value.scrollOffset.x,
|
|
550
|
+
y: scrollDetails.value.scrollOffset.y,
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Handles pointer move events on the container to perform emulated scrolling.
|
|
558
|
+
*
|
|
559
|
+
* @param event - The pointer move event.
|
|
560
|
+
*/
|
|
561
|
+
function handlePointerMove(event: PointerEvent) {
|
|
562
|
+
if (!isPointerScrolling.value) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const now = performance.now();
|
|
567
|
+
const dt = now - lastPointerTime;
|
|
568
|
+
|
|
569
|
+
if (dt > 0) {
|
|
570
|
+
// Calculate instantaneous velocity (pixels per millisecond)
|
|
571
|
+
const instantVelocityX = (lastPointerPos.x - event.clientX) / dt;
|
|
572
|
+
const instantVelocityY = (lastPointerPos.y - event.clientY) / dt;
|
|
573
|
+
|
|
574
|
+
// Use a moving average for smoother velocity tracking
|
|
575
|
+
velocity.x = velocity.x * 0.2 + instantVelocityX * 0.8;
|
|
576
|
+
velocity.y = velocity.y * 0.2 + instantVelocityY * 0.8;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
lastPointerPos = { x: event.clientX, y: event.clientY };
|
|
580
|
+
lastPointerTime = now;
|
|
581
|
+
|
|
582
|
+
const deltaX = startPointerPos.x - event.clientX;
|
|
583
|
+
const deltaY = startPointerPos.y - event.clientY;
|
|
584
|
+
|
|
585
|
+
requestAnimationFrame(() => {
|
|
586
|
+
scrollToOffset(
|
|
587
|
+
startScrollOffset.x + deltaX,
|
|
588
|
+
startScrollOffset.y + deltaY,
|
|
589
|
+
{ behavior: 'auto' },
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Handles pointer up and cancel events to end emulated scrolling.
|
|
596
|
+
*
|
|
597
|
+
* @param event - The pointer event.
|
|
598
|
+
*/
|
|
599
|
+
function handlePointerUp(event: PointerEvent) {
|
|
600
|
+
if (!isPointerScrolling.value) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
isPointerScrolling.value = false;
|
|
605
|
+
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
606
|
+
|
|
607
|
+
// If the user was moving fast enough, start the inertia loop
|
|
608
|
+
if (Math.abs(velocity.x) > MIN_VELOCITY || Math.abs(velocity.y) > MIN_VELOCITY) {
|
|
609
|
+
// avoid unwanted cross-axis drift
|
|
610
|
+
if (Math.abs(velocity.x) > 4 * Math.abs(velocity.y)) {
|
|
611
|
+
velocity.y = 0;
|
|
612
|
+
} else if (Math.abs(velocity.y) > 4 * Math.abs(velocity.x)) {
|
|
613
|
+
velocity.x = 0;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
startInertiaAnimation();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Handles mouse wheel events to support high-precision scrolling for large content or virtual scrollbars.
|
|
622
|
+
*
|
|
623
|
+
* @param event - The wheel event.
|
|
624
|
+
*/
|
|
625
|
+
function handleWheel(event: WheelEvent) {
|
|
626
|
+
const { scrollOffset } = scrollDetails.value;
|
|
627
|
+
stopProgrammaticScroll();
|
|
628
|
+
|
|
629
|
+
if (useVirtualScrolling.value) {
|
|
630
|
+
// Prevent default browser scroll as we are handling it manually
|
|
631
|
+
event.preventDefault();
|
|
632
|
+
|
|
633
|
+
// For large content we manually scroll to keep precision/control
|
|
634
|
+
let deltaX = event.deltaX;
|
|
635
|
+
let deltaY = event.deltaY;
|
|
636
|
+
|
|
637
|
+
if (event.shiftKey && deltaX === 0) {
|
|
638
|
+
deltaX = deltaY;
|
|
639
|
+
deltaY = 0;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const targetX = scrollOffset.x + deltaX;
|
|
643
|
+
const targetY = scrollOffset.y + deltaY;
|
|
644
|
+
|
|
645
|
+
scrollToOffset(targetX, targetY, { behavior: 'auto' });
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Handles keyboard events for navigation (Home, End, Arrows, PageUp/Down).
|
|
651
|
+
*
|
|
652
|
+
* @param event - The keyboard event.
|
|
653
|
+
*/
|
|
571
654
|
function handleKeyDown(event: KeyboardEvent) {
|
|
572
655
|
const { viewportSize, scrollOffset } = scrollDetails.value;
|
|
573
656
|
const isHorizontal = props.direction !== 'vertical';
|
|
574
657
|
const isVertical = props.direction !== 'horizontal';
|
|
575
658
|
|
|
659
|
+
const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
|
|
660
|
+
const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
|
|
661
|
+
|
|
576
662
|
switch (event.key) {
|
|
577
|
-
case 'Home':
|
|
663
|
+
case 'Home': {
|
|
578
664
|
event.preventDefault();
|
|
579
665
|
stopProgrammaticScroll();
|
|
580
|
-
|
|
666
|
+
const distance = Math.max(scrollOffset.x, scrollOffset.y);
|
|
667
|
+
const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
|
|
668
|
+
const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
|
|
669
|
+
|
|
670
|
+
scrollToIndex(0, 0, { behavior, align: 'start' });
|
|
581
671
|
break;
|
|
672
|
+
}
|
|
582
673
|
case 'End': {
|
|
583
674
|
event.preventDefault();
|
|
584
675
|
stopProgrammaticScroll();
|
|
585
676
|
const lastItemIndex = props.items.length - 1;
|
|
586
677
|
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
587
678
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
679
|
+
const { totalSize } = scrollDetails.value;
|
|
680
|
+
const distance = Math.max(
|
|
681
|
+
totalSize.width - scrollOffset.x - viewportSize.width,
|
|
682
|
+
totalSize.height - scrollOffset.y - viewportSize.height,
|
|
683
|
+
);
|
|
684
|
+
const viewport = props.direction === 'horizontal' ? viewportSize.width : viewportSize.height;
|
|
685
|
+
const behavior = distance > 10 * viewport ? 'auto' : 'smooth';
|
|
686
|
+
|
|
687
|
+
if (props.direction === 'both') {
|
|
688
|
+
scrollToIndex(lastItemIndex, lastColIndex, { behavior, align: 'end' });
|
|
594
689
|
} else {
|
|
595
|
-
scrollToIndex(
|
|
690
|
+
scrollToIndex(
|
|
691
|
+
props.direction === 'vertical' ? lastItemIndex : 0,
|
|
692
|
+
props.direction === 'horizontal' ? lastItemIndex : 0,
|
|
693
|
+
{ behavior, align: 'end' },
|
|
694
|
+
);
|
|
596
695
|
}
|
|
597
696
|
break;
|
|
598
697
|
}
|
|
599
|
-
case 'ArrowUp':
|
|
698
|
+
case 'ArrowUp': {
|
|
600
699
|
event.preventDefault();
|
|
601
700
|
stopProgrammaticScroll();
|
|
602
|
-
|
|
701
|
+
if (!isVertical) {
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
const { currentIndex, scrollOffset } = scrollDetails.value;
|
|
706
|
+
const viewportTop = scrollOffset.y + sStart.y + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y;
|
|
707
|
+
const itemPos = getRowOffset(currentIndex);
|
|
708
|
+
|
|
709
|
+
if (itemPos < viewportTop - 1) {
|
|
710
|
+
scrollToIndex(currentIndex, null, { align: 'start' });
|
|
711
|
+
} else if (currentIndex > 0) {
|
|
712
|
+
scrollToIndex(currentIndex - 1, null, { align: 'start' });
|
|
713
|
+
}
|
|
603
714
|
break;
|
|
604
|
-
|
|
715
|
+
}
|
|
716
|
+
case 'ArrowDown': {
|
|
605
717
|
event.preventDefault();
|
|
606
718
|
stopProgrammaticScroll();
|
|
607
|
-
|
|
719
|
+
if (!isVertical) {
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const { currentEndIndex } = scrollDetails.value;
|
|
724
|
+
const viewportBottom = scrollOffset.y + viewportSize.height - (sEnd.y + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).y);
|
|
725
|
+
const itemBottom = getRowOffset(currentEndIndex) + getRowHeight(currentEndIndex);
|
|
726
|
+
|
|
727
|
+
if (itemBottom > viewportBottom + 1) {
|
|
728
|
+
scrollToIndex(currentEndIndex, null, { align: 'end' });
|
|
729
|
+
} else if (currentEndIndex < props.items.length - 1) {
|
|
730
|
+
scrollToIndex(currentEndIndex + 1, null, { align: 'end' });
|
|
731
|
+
}
|
|
608
732
|
break;
|
|
609
|
-
|
|
733
|
+
}
|
|
734
|
+
case 'ArrowLeft': {
|
|
610
735
|
event.preventDefault();
|
|
611
736
|
stopProgrammaticScroll();
|
|
612
|
-
|
|
737
|
+
if (!isHorizontal) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const { currentColIndex, currentEndColIndex } = scrollDetails.value;
|
|
742
|
+
|
|
743
|
+
if (isRtl.value) {
|
|
744
|
+
// RTL ArrowLeft -> towards logical END (Left)
|
|
745
|
+
const viewportLeft = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
|
|
746
|
+
const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
|
|
747
|
+
|
|
748
|
+
if (colEndPos > viewportLeft + 1) {
|
|
749
|
+
scrollToIndex(null, currentEndColIndex, { align: 'end' });
|
|
750
|
+
} else {
|
|
751
|
+
const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
|
|
752
|
+
if (currentEndColIndex < maxColIdx) {
|
|
753
|
+
scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
} else {
|
|
757
|
+
// LTR ArrowLeft -> towards logical START (Left)
|
|
758
|
+
const viewportLeft = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
|
|
759
|
+
const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
|
|
760
|
+
|
|
761
|
+
if (colStartPos < viewportLeft - 1) {
|
|
762
|
+
scrollToIndex(null, currentColIndex, { align: 'start' });
|
|
763
|
+
} else if (currentColIndex > 0) {
|
|
764
|
+
scrollToIndex(null, currentColIndex - 1, { align: 'start' });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
613
767
|
break;
|
|
614
|
-
|
|
768
|
+
}
|
|
769
|
+
case 'ArrowRight': {
|
|
615
770
|
event.preventDefault();
|
|
616
771
|
stopProgrammaticScroll();
|
|
617
|
-
|
|
772
|
+
if (!isHorizontal) {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const { currentColIndex, currentEndColIndex } = scrollDetails.value;
|
|
777
|
+
|
|
778
|
+
if (isRtl.value) {
|
|
779
|
+
// RTL ArrowRight -> towards logical START (Right)
|
|
780
|
+
const viewportRight = scrollOffset.x + sStart.x + (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x;
|
|
781
|
+
const colStartPos = (props.columnCount ? getColumnOffset(currentColIndex) : getItemOffset(currentColIndex));
|
|
782
|
+
|
|
783
|
+
if (colStartPos < viewportRight - 1) {
|
|
784
|
+
scrollToIndex(null, currentColIndex, { align: 'start' });
|
|
785
|
+
} else if (currentColIndex > 0) {
|
|
786
|
+
scrollToIndex(null, currentColIndex - 1, { align: 'start' });
|
|
787
|
+
}
|
|
788
|
+
} else {
|
|
789
|
+
// LTR ArrowRight -> towards logical END (Right)
|
|
790
|
+
const viewportRight = scrollOffset.x + viewportSize.width - (sEnd.x + (virtualScrollProps.value.scrollPaddingEnd as { x: number; y: number; }).x);
|
|
791
|
+
const colEndPos = (props.columnCount ? getColumnOffset(currentEndColIndex) + getColumnWidth(currentEndColIndex) : getItemOffset(currentEndColIndex) + getItemSize(currentEndColIndex));
|
|
792
|
+
|
|
793
|
+
if (colEndPos > viewportRight + 1) {
|
|
794
|
+
scrollToIndex(null, currentEndColIndex, { align: 'end' });
|
|
795
|
+
} else {
|
|
796
|
+
const maxColIdx = props.columnCount ? props.columnCount - 1 : props.items.length - 1;
|
|
797
|
+
if (currentEndColIndex < maxColIdx) {
|
|
798
|
+
scrollToIndex(null, currentEndColIndex + 1, { align: 'end' });
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
|
618
802
|
break;
|
|
803
|
+
}
|
|
619
804
|
case 'PageUp':
|
|
620
805
|
event.preventDefault();
|
|
621
806
|
stopProgrammaticScroll();
|
|
@@ -641,30 +826,121 @@ onUnmounted(() => {
|
|
|
641
826
|
extraResizeObserver?.disconnect();
|
|
642
827
|
});
|
|
643
828
|
|
|
644
|
-
const isWindowContainer = computed(() => isWindowLike(props.container));
|
|
645
|
-
|
|
646
829
|
const containerStyle = computed(() => {
|
|
830
|
+
const base: Record<string, string | number | undefined> = {
|
|
831
|
+
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
if (showVirtualScrollbars.value || !isWindowContainer.value) {
|
|
835
|
+
base.overflow = 'auto';
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (useVirtualScrolling.value) {
|
|
839
|
+
base.touchAction = 'none';
|
|
840
|
+
}
|
|
841
|
+
|
|
647
842
|
if (isWindowContainer.value) {
|
|
648
|
-
return
|
|
649
|
-
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
650
|
-
};
|
|
843
|
+
return base;
|
|
651
844
|
}
|
|
652
845
|
|
|
653
846
|
if (props.containerTag === 'table') {
|
|
654
847
|
return {
|
|
848
|
+
...base,
|
|
849
|
+
display: 'block',
|
|
655
850
|
minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
|
|
656
851
|
};
|
|
657
852
|
}
|
|
658
853
|
|
|
854
|
+
return base;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const verticalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
858
|
+
if (props.direction === 'horizontal') {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
|
|
862
|
+
if (renderedHeight.value <= displayViewportSize.height) {
|
|
863
|
+
return null;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
const scrollbarProps: VirtualScrollbarProps = {
|
|
867
|
+
axis: 'vertical',
|
|
868
|
+
totalSize: renderedHeight.value,
|
|
869
|
+
position: displayScrollOffset.y,
|
|
870
|
+
viewportSize: displayViewportSize.height,
|
|
871
|
+
scrollToOffset: handleVerticalScrollbarScrollToOffset,
|
|
872
|
+
containerId: containerId.value,
|
|
873
|
+
isRtl: isRtl.value,
|
|
874
|
+
};
|
|
875
|
+
|
|
659
876
|
return {
|
|
660
|
-
|
|
877
|
+
axis: 'vertical',
|
|
878
|
+
positionPercent: verticalScrollbar.positionPercent.value,
|
|
879
|
+
viewportPercent: verticalScrollbar.viewportPercent.value,
|
|
880
|
+
thumbSizePercent: verticalScrollbar.thumbSizePercent.value,
|
|
881
|
+
thumbPositionPercent: verticalScrollbar.thumbPositionPercent.value,
|
|
882
|
+
trackProps: verticalScrollbar.trackProps.value,
|
|
883
|
+
thumbProps: verticalScrollbar.thumbProps.value,
|
|
884
|
+
scrollbarProps,
|
|
885
|
+
isDragging: verticalScrollbar.isDragging.value,
|
|
661
886
|
};
|
|
662
887
|
});
|
|
663
888
|
|
|
664
|
-
const
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
}
|
|
889
|
+
const horizontalScrollbarProps = computed<ScrollbarSlotProps | null>(() => {
|
|
890
|
+
if (props.direction === 'vertical') {
|
|
891
|
+
return null;
|
|
892
|
+
}
|
|
893
|
+
const { displayViewportSize, displayScrollOffset } = scrollDetails.value;
|
|
894
|
+
if (renderedWidth.value <= displayViewportSize.width) {
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const scrollbarProps: VirtualScrollbarProps = {
|
|
899
|
+
axis: 'horizontal',
|
|
900
|
+
totalSize: renderedWidth.value,
|
|
901
|
+
position: displayScrollOffset.x,
|
|
902
|
+
viewportSize: displayViewportSize.width,
|
|
903
|
+
scrollToOffset: handleHorizontalScrollbarScrollToOffset,
|
|
904
|
+
containerId: containerId.value,
|
|
905
|
+
isRtl: isRtl.value,
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
return {
|
|
909
|
+
axis: 'horizontal',
|
|
910
|
+
positionPercent: horizontalScrollbar.positionPercent.value,
|
|
911
|
+
viewportPercent: horizontalScrollbar.viewportPercent.value,
|
|
912
|
+
thumbSizePercent: horizontalScrollbar.thumbSizePercent.value,
|
|
913
|
+
thumbPositionPercent: horizontalScrollbar.thumbPositionPercent.value,
|
|
914
|
+
trackProps: horizontalScrollbar.trackProps.value,
|
|
915
|
+
thumbProps: horizontalScrollbar.thumbProps.value,
|
|
916
|
+
scrollbarProps,
|
|
917
|
+
isDragging: horizontalScrollbar.isDragging.value,
|
|
918
|
+
};
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
const wrapperStyle = computed(() => {
|
|
922
|
+
const isHorizontal = props.direction === 'horizontal';
|
|
923
|
+
const isVertical = props.direction === 'vertical';
|
|
924
|
+
const isBoth = props.direction === 'both';
|
|
925
|
+
|
|
926
|
+
const style: Record<string, string | number | undefined> = {
|
|
927
|
+
inlineSize: isVertical ? '100%' : `${ renderedVirtualWidth.value }px`,
|
|
928
|
+
blockSize: isHorizontal ? '100%' : `${ renderedVirtualHeight.value }px`,
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
if (!isHydrated.value) {
|
|
932
|
+
style.display = 'flex';
|
|
933
|
+
style.flexDirection = isHorizontal ? 'row' : 'column';
|
|
934
|
+
if ((isHorizontal || isBoth) && props.columnGap) {
|
|
935
|
+
style.columnGap = `${ props.columnGap }px`;
|
|
936
|
+
}
|
|
937
|
+
if ((isVertical || isBoth) && props.gap) {
|
|
938
|
+
style.rowGap = `${ props.gap }px`;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return style;
|
|
943
|
+
});
|
|
668
944
|
|
|
669
945
|
const loadingStyle = computed(() => {
|
|
670
946
|
const isHorizontal = props.direction === 'horizontal';
|
|
@@ -676,20 +952,36 @@ const loadingStyle = computed(() => {
|
|
|
676
952
|
});
|
|
677
953
|
|
|
678
954
|
const spacerStyle = computed(() => ({
|
|
679
|
-
inlineSize: props.direction === 'vertical' ? '1px' : `${
|
|
680
|
-
blockSize: props.direction === 'horizontal' ? '1px' : `${
|
|
955
|
+
inlineSize: props.direction === 'vertical' ? '1px' : `${ renderedVirtualWidth.value }px`,
|
|
956
|
+
blockSize: props.direction === 'horizontal' ? '1px' : `${ renderedVirtualHeight.value }px`,
|
|
681
957
|
}));
|
|
682
958
|
|
|
959
|
+
/**
|
|
960
|
+
* Calculates the final style object for an item, including position and dimensions.
|
|
961
|
+
*
|
|
962
|
+
* @param item - The rendered item state.
|
|
963
|
+
* @returns CSS style object.
|
|
964
|
+
*/
|
|
683
965
|
function getItemStyle(item: RenderedItem<T>) {
|
|
684
|
-
|
|
685
|
-
containerTag: props.containerTag,
|
|
966
|
+
const style = calculateItemStyle({
|
|
967
|
+
containerTag: props.containerTag || 'div',
|
|
686
968
|
direction: props.direction,
|
|
687
969
|
isHydrated: isHydrated.value,
|
|
688
970
|
item,
|
|
689
971
|
itemSize: props.itemSize,
|
|
690
972
|
paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
|
|
691
973
|
paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
|
|
974
|
+
isRtl: isRtl.value,
|
|
692
975
|
});
|
|
976
|
+
|
|
977
|
+
if (!isHydrated.value && props.direction === 'both') {
|
|
978
|
+
style.display = 'flex';
|
|
979
|
+
if (props.columnGap) {
|
|
980
|
+
style.columnGap = `${ props.columnGap }px`;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
return style;
|
|
693
985
|
}
|
|
694
986
|
|
|
695
987
|
const isDebug = computed(() => props.debug);
|
|
@@ -698,6 +990,8 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
|
|
|
698
990
|
const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
|
|
699
991
|
|
|
700
992
|
defineExpose({
|
|
993
|
+
...toRefs(props),
|
|
994
|
+
|
|
701
995
|
/**
|
|
702
996
|
* Detailed information about the current scroll state.
|
|
703
997
|
* @see ScrollDetails
|
|
@@ -719,6 +1013,41 @@ defineExpose({
|
|
|
719
1013
|
*/
|
|
720
1014
|
getColumnWidth,
|
|
721
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* Helper to get the height of a specific row.
|
|
1018
|
+
* @param index - The row index.
|
|
1019
|
+
* @see useVirtualScroll
|
|
1020
|
+
*/
|
|
1021
|
+
getRowHeight,
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Helper to get the virtual offset of a specific row.
|
|
1025
|
+
* @param index - The row index.
|
|
1026
|
+
* @see useVirtualScroll
|
|
1027
|
+
*/
|
|
1028
|
+
getRowOffset,
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Helper to get the virtual offset of a specific column.
|
|
1032
|
+
* @param index - The column index.
|
|
1033
|
+
* @see useVirtualScroll
|
|
1034
|
+
*/
|
|
1035
|
+
getColumnOffset,
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Helper to get the virtual offset of a specific item.
|
|
1039
|
+
* @param index - The item index.
|
|
1040
|
+
* @see useVirtualScroll
|
|
1041
|
+
*/
|
|
1042
|
+
getItemOffset,
|
|
1043
|
+
|
|
1044
|
+
/**
|
|
1045
|
+
* Helper to get the size of a specific item along the scroll axis.
|
|
1046
|
+
* @param index - The item index.
|
|
1047
|
+
* @see useVirtualScroll
|
|
1048
|
+
*/
|
|
1049
|
+
getItemSize,
|
|
1050
|
+
|
|
722
1051
|
/**
|
|
723
1052
|
* Programmatically scroll to a specific row and/or column.
|
|
724
1053
|
*
|
|
@@ -751,13 +1080,69 @@ defineExpose({
|
|
|
751
1080
|
* Immediately stops any currently active smooth scroll animation and clears pending corrections.
|
|
752
1081
|
* @see useVirtualScroll
|
|
753
1082
|
*/
|
|
754
|
-
stopProgrammaticScroll
|
|
1083
|
+
stopProgrammaticScroll: () => {
|
|
1084
|
+
stopProgrammaticScroll();
|
|
1085
|
+
stopInertia();
|
|
1086
|
+
},
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Detects the current direction (LTR/RTL) of the scroll container.
|
|
1090
|
+
*/
|
|
1091
|
+
updateDirection,
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Whether the scroll container is in Right-to-Left (RTL) mode.
|
|
1095
|
+
*/
|
|
1096
|
+
isRtl,
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Whether the component has finished its first client-side mount and hydration.
|
|
1100
|
+
*/
|
|
1101
|
+
isHydrated,
|
|
1102
|
+
|
|
1103
|
+
/**
|
|
1104
|
+
* Coordinate scaling factor for X axis.
|
|
1105
|
+
*/
|
|
1106
|
+
scaleX,
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Coordinate scaling factor for Y axis.
|
|
1110
|
+
*/
|
|
1111
|
+
scaleY,
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Physical width of the content in the DOM (clamped to browser limits).
|
|
1115
|
+
*/
|
|
1116
|
+
renderedWidth,
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Physical height of the content in the DOM (clamped to browser limits).
|
|
1120
|
+
*/
|
|
1121
|
+
renderedHeight,
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Absolute offset of the component within its container.
|
|
1125
|
+
*/
|
|
1126
|
+
componentOffset,
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Properties for the vertical scrollbar.
|
|
1130
|
+
* Useful when building custom scrollbar interfaces.
|
|
1131
|
+
*/
|
|
1132
|
+
scrollbarPropsVertical: verticalScrollbarProps,
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Properties for the horizontal scrollbar.
|
|
1136
|
+
* Useful when building custom scrollbar interfaces.
|
|
1137
|
+
*/
|
|
1138
|
+
scrollbarPropsHorizontal: horizontalScrollbarProps,
|
|
755
1139
|
});
|
|
756
1140
|
</script>
|
|
757
1141
|
|
|
758
1142
|
<template>
|
|
759
1143
|
<component
|
|
760
1144
|
:is="containerTag"
|
|
1145
|
+
:id="containerId"
|
|
761
1146
|
ref="hostRef"
|
|
762
1147
|
class="virtual-scroll-container"
|
|
763
1148
|
:class="[
|
|
@@ -766,15 +1151,37 @@ defineExpose({
|
|
|
766
1151
|
'virtual-scroll--hydrated': isHydrated,
|
|
767
1152
|
'virtual-scroll--window': isWindowContainer,
|
|
768
1153
|
'virtual-scroll--table': isTable,
|
|
1154
|
+
'virtual-scroll--hide-scrollbar': showVirtualScrollbars,
|
|
769
1155
|
},
|
|
770
1156
|
]"
|
|
771
1157
|
:style="containerStyle"
|
|
772
1158
|
tabindex="0"
|
|
773
1159
|
@keydown="handleKeyDown"
|
|
774
|
-
@
|
|
775
|
-
@
|
|
776
|
-
@
|
|
1160
|
+
@pointerdown="handlePointerDown"
|
|
1161
|
+
@pointermove="handlePointerMove"
|
|
1162
|
+
@pointerup="handlePointerUp"
|
|
1163
|
+
@pointercancel="handlePointerUp"
|
|
777
1164
|
>
|
|
1165
|
+
<div
|
|
1166
|
+
v-if="showVirtualScrollbars"
|
|
1167
|
+
class="virtual-scroll-scrollbar-container"
|
|
1168
|
+
>
|
|
1169
|
+
<div
|
|
1170
|
+
class="virtual-scroll-scrollbar-viewport"
|
|
1171
|
+
:style="{
|
|
1172
|
+
'inlineSize': `${ scrollDetails.displayViewportSize.width }px`,
|
|
1173
|
+
'blockSize': `${ scrollDetails.displayViewportSize.height }px`,
|
|
1174
|
+
'--vsi-scrollbar-has-cross-gap': direction === 'both' ? 1 : 0,
|
|
1175
|
+
}"
|
|
1176
|
+
>
|
|
1177
|
+
<slot v-if="slots.scrollbar && verticalScrollbarProps" name="scrollbar" v-bind="verticalScrollbarProps" />
|
|
1178
|
+
<VirtualScrollbar v-else-if="verticalScrollbarProps" v-bind="verticalScrollbarProps.scrollbarProps" />
|
|
1179
|
+
|
|
1180
|
+
<slot v-if="slots.scrollbar && horizontalScrollbarProps" name="scrollbar" v-bind="horizontalScrollbarProps" />
|
|
1181
|
+
<VirtualScrollbar v-else-if="horizontalScrollbarProps" v-bind="horizontalScrollbarProps.scrollbarProps" />
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1184
|
+
|
|
778
1185
|
<component
|
|
779
1186
|
:is="headerTag"
|
|
780
1187
|
v-if="slots.header"
|
|
@@ -818,11 +1225,17 @@ defineExpose({
|
|
|
818
1225
|
name="item"
|
|
819
1226
|
:item="renderedItem.item"
|
|
820
1227
|
:index="renderedItem.index"
|
|
821
|
-
:column-range="
|
|
1228
|
+
:column-range="slotColumnRange"
|
|
822
1229
|
:get-column-width="getColumnWidth"
|
|
1230
|
+
:gap="props.gap"
|
|
1231
|
+
:column-gap="props.columnGap"
|
|
823
1232
|
:is-sticky="renderedItem.isSticky"
|
|
824
1233
|
:is-sticky-active="renderedItem.isStickyActive"
|
|
1234
|
+
:is-sticky-active-x="renderedItem.isStickyActiveX"
|
|
1235
|
+
:is-sticky-active-y="renderedItem.isStickyActiveY"
|
|
1236
|
+
:offset="renderedItem.offset"
|
|
825
1237
|
/>
|
|
1238
|
+
|
|
826
1239
|
<div v-if="isDebug" class="virtual-scroll-debug-info">
|
|
827
1240
|
#{{ renderedItem.index }} ({{ Math.round(renderedItem.offset.x) }}, {{ Math.round(renderedItem.offset.y) }})
|
|
828
1241
|
</div>
|
|
@@ -850,109 +1263,139 @@ defineExpose({
|
|
|
850
1263
|
</template>
|
|
851
1264
|
|
|
852
1265
|
<style scoped>
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1266
|
+
@layer components {
|
|
1267
|
+
.virtual-scroll-container {
|
|
1268
|
+
position: relative;
|
|
1269
|
+
block-size: 100%;
|
|
1270
|
+
inline-size: 100%;
|
|
1271
|
+
outline-offset: 1px;
|
|
1272
|
+
|
|
1273
|
+
&:not(.virtual-scroll--window) {
|
|
1274
|
+
overflow: auto;
|
|
1275
|
+
overscroll-behavior: contain;
|
|
1276
|
+
}
|
|
858
1277
|
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
1278
|
+
&.virtual-scroll--table {
|
|
1279
|
+
display: block;
|
|
1280
|
+
}
|
|
863
1281
|
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
}
|
|
1282
|
+
&.virtual-scroll--hide-scrollbar {
|
|
1283
|
+
scrollbar-width: none;
|
|
1284
|
+
-ms-overflow-style: none;
|
|
868
1285
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
}
|
|
1286
|
+
&::-webkit-scrollbar {
|
|
1287
|
+
display: none;
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
872
1290
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1291
|
+
&.virtual-scroll--horizontal,
|
|
1292
|
+
&.virtual-scroll--both {
|
|
1293
|
+
white-space: nowrap;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
876
1296
|
|
|
877
|
-
|
|
878
|
-
position:
|
|
1297
|
+
.virtual-scroll-scrollbar-container {
|
|
1298
|
+
position: sticky;
|
|
879
1299
|
inset-block-start: 0;
|
|
880
1300
|
inset-inline-start: 0;
|
|
1301
|
+
inline-size: 100%;
|
|
1302
|
+
block-size: 0;
|
|
1303
|
+
z-index: 30;
|
|
1304
|
+
pointer-events: none;
|
|
1305
|
+
overflow: visible;
|
|
881
1306
|
}
|
|
882
|
-
}
|
|
883
1307
|
|
|
884
|
-
.virtual-scroll-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1308
|
+
.virtual-scroll-scrollbar-viewport {
|
|
1309
|
+
position: absolute;
|
|
1310
|
+
inset-block-start: 0;
|
|
1311
|
+
inset-inline-start: 0;
|
|
1312
|
+
pointer-events: none;
|
|
1313
|
+
}
|
|
888
1314
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
1315
|
+
.virtual-scroll-wrapper {
|
|
1316
|
+
contain: layout;
|
|
1317
|
+
position: relative;
|
|
892
1318
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
1319
|
+
:where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
|
|
1320
|
+
position: absolute;
|
|
1321
|
+
inset-block-start: 0;
|
|
1322
|
+
inset-inline-start: 0;
|
|
896
1323
|
}
|
|
897
1324
|
}
|
|
898
|
-
}
|
|
899
1325
|
|
|
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
|
-
}
|
|
1326
|
+
.virtual-scroll-item {
|
|
1327
|
+
display: grid;
|
|
1328
|
+
box-sizing: border-box;
|
|
1329
|
+
will-change: transform;
|
|
913
1330
|
|
|
914
|
-
.virtual-scroll
|
|
915
|
-
|
|
916
|
-
|
|
1331
|
+
&:where(.virtual-scroll--debug) {
|
|
1332
|
+
outline: 1px dashed rgba(255, 0, 0, 0.5);
|
|
1333
|
+
background-color: rgba(255, 0, 0, 0.05);
|
|
917
1334
|
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
}
|
|
1335
|
+
&:where(:hover) {
|
|
1336
|
+
background-color: rgba(255, 0, 0, 0.1);
|
|
1337
|
+
z-index: 100;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
923
1341
|
|
|
924
|
-
.virtual-scroll
|
|
925
|
-
|
|
1342
|
+
.virtual-scroll-debug-info {
|
|
1343
|
+
position: absolute;
|
|
1344
|
+
inset-block-start: 2px;
|
|
1345
|
+
inset-inline-end: 2px;
|
|
1346
|
+
background: rgba(0, 0, 0, 0.7);
|
|
1347
|
+
color: white;
|
|
1348
|
+
font-size: 10px;
|
|
1349
|
+
padding: 2px 4px;
|
|
1350
|
+
border-radius: 4px;
|
|
1351
|
+
pointer-events: none;
|
|
1352
|
+
z-index: 100;
|
|
1353
|
+
font-family: monospace;
|
|
1354
|
+
}
|
|
926
1355
|
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
inset-inline-start: 0;
|
|
930
|
-
min-inline-size: 100%;
|
|
931
|
-
box-sizing: border-box;
|
|
1356
|
+
.virtual-scroll-spacer {
|
|
1357
|
+
pointer-events: none;
|
|
932
1358
|
}
|
|
933
1359
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
box-sizing: border-box;
|
|
1360
|
+
.virtual-scroll-header,
|
|
1361
|
+
.virtual-scroll-footer {
|
|
1362
|
+
position: relative;
|
|
1363
|
+
z-index: 20;
|
|
939
1364
|
}
|
|
940
1365
|
|
|
941
|
-
|
|
942
|
-
|
|
1366
|
+
.virtual-scroll--sticky {
|
|
1367
|
+
position: sticky;
|
|
1368
|
+
|
|
1369
|
+
&:where(.virtual-scroll-header) {
|
|
1370
|
+
inset-block-start: 0;
|
|
1371
|
+
inset-inline-start: 0;
|
|
1372
|
+
min-inline-size: 100%;
|
|
1373
|
+
box-sizing: border-box;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
&:where(.virtual-scroll-footer) {
|
|
1377
|
+
inset-block-end: 0;
|
|
1378
|
+
inset-inline-start: 0;
|
|
1379
|
+
min-inline-size: 100%;
|
|
1380
|
+
box-sizing: border-box;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
&:where(.virtual-scroll-item) {
|
|
1384
|
+
z-index: 10;
|
|
1385
|
+
}
|
|
943
1386
|
}
|
|
944
|
-
}
|
|
945
1387
|
|
|
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) {
|
|
1388
|
+
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
950
1389
|
display: inline-flex;
|
|
951
1390
|
min-inline-size: 100%;
|
|
1391
|
+
& > :deep(tr) {
|
|
1392
|
+
display: inline-flex;
|
|
1393
|
+
min-inline-size: 100%;
|
|
952
1394
|
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1395
|
+
& > :is(td, th) {
|
|
1396
|
+
display: inline-block;
|
|
1397
|
+
align-items: center;
|
|
1398
|
+
}
|
|
956
1399
|
}
|
|
957
1400
|
}
|
|
958
1401
|
}
|