@pdanpdan/virtual-scroll 0.3.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 +268 -275
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1497 -192
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2219 -896
- 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 +1979 -627
- package/src/components/VirtualScroll.vue +951 -349
- package/src/components/VirtualScrollbar.test.ts +174 -0
- package/src/components/VirtualScrollbar.vue +102 -0
- package/src/composables/useVirtualScroll.test.ts +1160 -1521
- package/src/composables/useVirtualScroll.ts +1135 -791
- package/src/composables/useVirtualScrollbar.test.ts +526 -0
- package/src/composables/useVirtualScrollbar.ts +239 -0
- package/src/index.ts +4 -0
- package/src/types.ts +816 -0
- package/src/utils/fenwick-tree.test.ts +39 -39
- package/src/utils/fenwick-tree.ts +38 -18
- package/src/utils/scroll.test.ts +174 -0
- package/src/utils/scroll.ts +50 -13
- package/src/utils/virtual-scroll-logic.test.ts +2850 -0
- package/src/utils/virtual-scroll-logic.ts +901 -0
|
@@ -1,82 +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
|
-
} from '../
|
|
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';
|
|
24
|
+
import { useVirtualScrollbar } from '../composables/useVirtualScrollbar';
|
|
17
25
|
import { getPaddingX, getPaddingY } from '../utils/scroll';
|
|
26
|
+
import { calculateItemStyle, displayToVirtual } from '../utils/virtual-scroll-logic';
|
|
27
|
+
import VirtualScrollbar from './VirtualScrollbar.vue';
|
|
18
28
|
|
|
19
|
-
export interface Props<T = unknown> {
|
|
20
|
-
/** Array of items to be virtualized. */
|
|
21
|
-
items: T[];
|
|
22
|
-
/** Fixed size of each item or a function that returns the size of an item. Pass 0, null or undefined for dynamic size detection. */
|
|
23
|
-
itemSize?: number | ((item: T, index: number) => number) | null;
|
|
24
|
-
/** Direction of the scroll: 'vertical', 'horizontal', or 'both' (grid). */
|
|
25
|
-
direction?: 'vertical' | 'horizontal' | 'both';
|
|
26
|
-
/** Number of items to render before the visible viewport. */
|
|
27
|
-
bufferBefore?: number;
|
|
28
|
-
/** Number of items to render after the visible viewport. */
|
|
29
|
-
bufferAfter?: number;
|
|
30
|
-
/** The scrollable container element or window. If not provided, the host element is used. */
|
|
31
|
-
container?: HTMLElement | Window | null;
|
|
32
|
-
/** Range of items to render for SSR. */
|
|
33
|
-
ssrRange?: {
|
|
34
|
-
start: number;
|
|
35
|
-
end: number;
|
|
36
|
-
colStart?: number;
|
|
37
|
-
colEnd?: number;
|
|
38
|
-
};
|
|
39
|
-
/** Number of columns for bidirectional (grid) scroll. */
|
|
40
|
-
columnCount?: number;
|
|
41
|
-
/** Fixed width of columns or an array/function for column widths. Pass 0, null or undefined for dynamic width. */
|
|
42
|
-
columnWidth?: number | number[] | ((index: number) => number) | null;
|
|
43
|
-
/** The HTML tag to use for the container. */
|
|
44
|
-
containerTag?: string;
|
|
45
|
-
/** The HTML tag to use for the items wrapper. */
|
|
46
|
-
wrapperTag?: string;
|
|
47
|
-
/** The HTML tag to use for each item. */
|
|
48
|
-
itemTag?: string;
|
|
49
|
-
/** Padding at the start of the scroll container. */
|
|
50
|
-
scrollPaddingStart?: number | { x?: number; y?: number; };
|
|
51
|
-
/** Padding at the end of the scroll container. */
|
|
52
|
-
scrollPaddingEnd?: number | { x?: number; y?: number; };
|
|
53
|
-
/** Whether the header slot content is sticky and should be accounted for in scroll padding. If true, header size is automatically measured. */
|
|
54
|
-
stickyHeader?: boolean;
|
|
55
|
-
/** Whether the footer slot content is sticky and should be accounted for in scroll padding. If true, footer size is automatically measured. */
|
|
56
|
-
stickyFooter?: boolean;
|
|
57
|
-
/** Gap between items in pixels (vertical). */
|
|
58
|
-
gap?: number;
|
|
59
|
-
/** Gap between columns in pixels (horizontal/grid). */
|
|
60
|
-
columnGap?: number;
|
|
61
|
-
/** Indices of items that should stick to the top/start. Supports iOS-style pushing effect. */
|
|
62
|
-
stickyIndices?: number[];
|
|
63
|
-
/** Distance from the end of the scrollable area to trigger 'load' event in pixels. */
|
|
64
|
-
loadDistance?: number;
|
|
65
|
-
/** Whether items are currently being loaded. Prevents multiple 'load' events and shows 'loading' slot. */
|
|
66
|
-
loading?: boolean;
|
|
67
|
-
/** Whether to automatically restore scroll position when items are prepended to the list. */
|
|
68
|
-
restoreScrollOnPrepend?: boolean;
|
|
69
|
-
/** Initial scroll index to jump to on mount. */
|
|
70
|
-
initialScrollIndex?: number;
|
|
71
|
-
/** Alignment for the initial scroll index. */
|
|
72
|
-
initialScrollAlign?: ScrollAlignment | ScrollAlignmentOptions;
|
|
73
|
-
/** Default size for items before they are measured. */
|
|
74
|
-
defaultItemSize?: number;
|
|
75
|
-
/** Default width for columns before they are measured. */
|
|
76
|
-
defaultColumnWidth?: number;
|
|
77
|
-
/** Whether to show debug information (buffers and offsets). */
|
|
78
|
-
debug?: boolean;
|
|
79
|
-
}
|
|
29
|
+
export interface Props<T = unknown> extends VirtualScrollComponentProps<T> {}
|
|
80
30
|
|
|
81
31
|
const props = withDefaults(defineProps<Props<T>>(), {
|
|
82
32
|
direction: 'vertical',
|
|
@@ -97,6 +47,7 @@ const props = withDefaults(defineProps<Props<T>>(), {
|
|
|
97
47
|
loading: false,
|
|
98
48
|
restoreScrollOnPrepend: false,
|
|
99
49
|
debug: false,
|
|
50
|
+
virtualScrollbar: false,
|
|
100
51
|
});
|
|
101
52
|
|
|
102
53
|
const emit = defineEmits<{
|
|
@@ -106,27 +57,66 @@ const emit = defineEmits<{
|
|
|
106
57
|
}>();
|
|
107
58
|
|
|
108
59
|
const slots = defineSlots<{
|
|
109
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* Content rendered at the top of the scrollable area.
|
|
62
|
+
* Can be made sticky using the `stickyHeader` prop.
|
|
63
|
+
*/
|
|
110
64
|
header?: (props: Record<string, never>) => VNodeChild;
|
|
111
|
-
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Scoped slot for rendering each individual item.
|
|
68
|
+
*/
|
|
112
69
|
item?: (props: {
|
|
113
|
-
/** The data item
|
|
70
|
+
/** The original data item from the `items` array. */
|
|
114
71
|
item: T;
|
|
115
|
-
/** The index of the item in the items array. */
|
|
72
|
+
/** The original index of the item in the `items` array. */
|
|
116
73
|
index: number;
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Information about the current visible range of columns (for grid mode).
|
|
76
|
+
* @see ColumnRange
|
|
77
|
+
*/
|
|
78
|
+
columnRange: {
|
|
79
|
+
/** Index of the first rendered column. */
|
|
80
|
+
start: number;
|
|
81
|
+
/** Index of the last rendered column (exclusive). */
|
|
82
|
+
end: number;
|
|
83
|
+
/** Pixel offset from the start of the row to the first rendered cell. */
|
|
84
|
+
padStart: number;
|
|
85
|
+
/** Pixel offset from the last rendered cell to the end of the row. */
|
|
86
|
+
padEnd: number;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Helper function to get the width of a specific column.
|
|
90
|
+
* Useful for setting consistent widths in grid mode.
|
|
91
|
+
*/
|
|
120
92
|
getColumnWidth: (index: number) => number;
|
|
121
|
-
/**
|
|
93
|
+
/** Vertical gap between items. */
|
|
94
|
+
gap: number;
|
|
95
|
+
/** Horizontal gap between columns. */
|
|
96
|
+
columnGap: number;
|
|
97
|
+
/** Whether this item is configured to be sticky via `stickyIndices`. */
|
|
122
98
|
isSticky?: boolean | undefined;
|
|
123
|
-
/** Whether this item is currently in a sticky state. */
|
|
99
|
+
/** Whether this item is currently in a sticky state (stuck at the top/start). */
|
|
124
100
|
isStickyActive?: boolean | undefined;
|
|
125
101
|
}) => VNodeChild;
|
|
126
|
-
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Content shown at the end of the list when the `loading` prop is true.
|
|
105
|
+
* Also prevents additional 'load' events from triggering while visible.
|
|
106
|
+
*/
|
|
127
107
|
loading?: (props: Record<string, never>) => VNodeChild;
|
|
128
|
-
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Content rendered at the bottom of the scrollable area.
|
|
111
|
+
* Can be made sticky using the `stickyFooter` prop.
|
|
112
|
+
*/
|
|
129
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;
|
|
130
120
|
}>();
|
|
131
121
|
|
|
132
122
|
const hostRef = ref<HTMLElement | null>(null);
|
|
@@ -135,37 +125,30 @@ const headerRef = ref<HTMLElement | null>(null);
|
|
|
135
125
|
const footerRef = ref<HTMLElement | null>(null);
|
|
136
126
|
const itemRefs = new Map<number, HTMLElement>();
|
|
137
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
|
+
|
|
138
136
|
const measuredPaddingStart = ref(0);
|
|
139
137
|
const measuredPaddingEnd = ref(0);
|
|
140
138
|
|
|
139
|
+
const effectiveContainer = computed(() => (props.container === undefined ? hostRef.value : props.container));
|
|
140
|
+
|
|
141
141
|
const isHeaderFooterInsideContainer = computed(() => {
|
|
142
|
-
const container =
|
|
143
|
-
? hostRef.value
|
|
144
|
-
: props.container;
|
|
142
|
+
const container = effectiveContainer.value;
|
|
145
143
|
|
|
146
144
|
return container === hostRef.value
|
|
147
145
|
|| (typeof window !== 'undefined' && (container === window || container === null));
|
|
148
146
|
});
|
|
149
147
|
|
|
150
148
|
const virtualScrollProps = computed(() => {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
/* v8 ignore start -- @preserve */
|
|
155
|
-
const startX = typeof pStart === 'object'
|
|
156
|
-
? (pStart.x || 0)
|
|
157
|
-
: (props.direction === 'horizontal' ? (pStart || 0) : 0);
|
|
158
|
-
const startY = typeof pStart === 'object'
|
|
159
|
-
? (pStart.y || 0)
|
|
160
|
-
: (props.direction !== 'horizontal' ? (pStart || 0) : 0);
|
|
161
|
-
|
|
162
|
-
const endX = typeof pEnd === 'object'
|
|
163
|
-
? (pEnd.x || 0)
|
|
164
|
-
: (props.direction === 'horizontal' ? (pEnd || 0) : 0);
|
|
165
|
-
const endY = typeof pEnd === 'object'
|
|
166
|
-
? (pEnd.y || 0)
|
|
167
|
-
: (props.direction !== 'horizontal' ? (pEnd || 0) : 0);
|
|
168
|
-
/* v8 ignore stop -- @preserve */
|
|
149
|
+
/* Trigger re-evaluation on items array mutations */
|
|
150
|
+
// eslint-disable-next-line ts/no-unused-expressions
|
|
151
|
+
props.items.length;
|
|
169
152
|
|
|
170
153
|
return {
|
|
171
154
|
items: props.items,
|
|
@@ -173,20 +156,35 @@ const virtualScrollProps = computed(() => {
|
|
|
173
156
|
direction: props.direction,
|
|
174
157
|
bufferBefore: props.bufferBefore,
|
|
175
158
|
bufferAfter: props.bufferAfter,
|
|
176
|
-
container:
|
|
177
|
-
? hostRef.value
|
|
178
|
-
: props.container,
|
|
159
|
+
container: effectiveContainer.value,
|
|
179
160
|
hostElement: wrapperRef.value,
|
|
161
|
+
hostRef: hostRef.value,
|
|
180
162
|
ssrRange: props.ssrRange,
|
|
181
163
|
columnCount: props.columnCount,
|
|
182
164
|
columnWidth: props.columnWidth,
|
|
183
165
|
scrollPaddingStart: {
|
|
184
|
-
x:
|
|
185
|
-
y:
|
|
166
|
+
x: getPaddingX(props.scrollPaddingStart, props.direction),
|
|
167
|
+
y: getPaddingY(props.scrollPaddingStart, props.direction),
|
|
186
168
|
},
|
|
187
169
|
scrollPaddingEnd: {
|
|
188
|
-
x:
|
|
189
|
-
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,
|
|
190
188
|
},
|
|
191
189
|
gap: props.gap,
|
|
192
190
|
columnGap: props.columnGap,
|
|
@@ -204,31 +202,106 @@ const virtualScrollProps = computed(() => {
|
|
|
204
202
|
|
|
205
203
|
const {
|
|
206
204
|
isHydrated,
|
|
205
|
+
isRtl,
|
|
207
206
|
columnRange,
|
|
208
207
|
renderedItems,
|
|
209
208
|
scrollDetails,
|
|
210
|
-
|
|
211
|
-
|
|
209
|
+
renderedHeight,
|
|
210
|
+
renderedWidth,
|
|
212
211
|
getColumnWidth,
|
|
212
|
+
getRowHeight,
|
|
213
213
|
scrollToIndex,
|
|
214
214
|
scrollToOffset,
|
|
215
215
|
updateHostOffset,
|
|
216
216
|
updateItemSizes,
|
|
217
|
+
updateDirection,
|
|
218
|
+
getItemOffset,
|
|
219
|
+
getRowOffset,
|
|
220
|
+
getColumnOffset,
|
|
221
|
+
getItemSize,
|
|
217
222
|
refresh: coreRefresh,
|
|
218
223
|
stopProgrammaticScroll,
|
|
224
|
+
scaleX,
|
|
225
|
+
scaleY,
|
|
226
|
+
isWindowContainer,
|
|
227
|
+
componentOffset,
|
|
228
|
+
renderedVirtualWidth,
|
|
229
|
+
renderedVirtualHeight,
|
|
219
230
|
} = useVirtualScroll(virtualScrollProps);
|
|
220
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
|
+
|
|
221
294
|
/**
|
|
222
295
|
* Resets all dynamic measurements and re-initializes from props.
|
|
223
296
|
* Also triggers manual re-measurement of all currently rendered items.
|
|
224
297
|
*/
|
|
225
298
|
function refresh() {
|
|
226
299
|
coreRefresh();
|
|
300
|
+
updateDirection();
|
|
227
301
|
nextTick(() => {
|
|
228
302
|
const updates: { index: number; inlineSize: number; blockSize: number; element?: HTMLElement; }[] = [];
|
|
229
303
|
|
|
230
304
|
for (const [ index, el ] of itemRefs.entries()) {
|
|
231
|
-
/* v8 ignore else -- @preserve */
|
|
232
305
|
if (el) {
|
|
233
306
|
updates.push({
|
|
234
307
|
index,
|
|
@@ -247,13 +320,15 @@ function refresh() {
|
|
|
247
320
|
|
|
248
321
|
// Watch for scroll details and emit event
|
|
249
322
|
watch(scrollDetails, (details, oldDetails) => {
|
|
250
|
-
if (!isHydrated.value) {
|
|
323
|
+
if (!isHydrated.value || !details) {
|
|
251
324
|
return;
|
|
252
325
|
}
|
|
253
326
|
emit('scroll', details);
|
|
254
327
|
|
|
255
328
|
if (
|
|
256
329
|
!oldDetails
|
|
330
|
+
|| !oldDetails.range
|
|
331
|
+
|| !oldDetails.columnRange
|
|
257
332
|
|| details.range.start !== oldDetails.range.start
|
|
258
333
|
|| details.range.end !== oldDetails.range.end
|
|
259
334
|
|| details.columnRange.start !== oldDetails.columnRange.start
|
|
@@ -272,14 +347,14 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
272
347
|
}
|
|
273
348
|
|
|
274
349
|
// vertical or both
|
|
275
|
-
if (props.direction !== 'horizontal') {
|
|
350
|
+
if (props.direction !== 'horizontal' && details.totalSize) {
|
|
276
351
|
const remaining = details.totalSize.height - (details.scrollOffset.y + details.viewportSize.height);
|
|
277
352
|
if (remaining <= props.loadDistance) {
|
|
278
353
|
emit('load', 'vertical');
|
|
279
354
|
}
|
|
280
355
|
}
|
|
281
356
|
// horizontal or both
|
|
282
|
-
if (props.direction !== 'vertical') {
|
|
357
|
+
if (props.direction !== 'vertical' && details.totalSize) {
|
|
283
358
|
const remaining = details.totalSize.width - (details.scrollOffset.x + details.viewportSize.width);
|
|
284
359
|
if (remaining <= props.loadDistance) {
|
|
285
360
|
emit('load', 'horizontal');
|
|
@@ -288,8 +363,7 @@ watch(scrollDetails, (details, oldDetails) => {
|
|
|
288
363
|
});
|
|
289
364
|
|
|
290
365
|
watch(isHydrated, (hydrated) => {
|
|
291
|
-
|
|
292
|
-
if (hydrated) {
|
|
366
|
+
if (hydrated && scrollDetails.value?.range && scrollDetails.value?.columnRange) {
|
|
293
367
|
emit('visibleRangeChange', {
|
|
294
368
|
start: scrollDetails.value.range.start,
|
|
295
369
|
end: scrollDetails.value.range.end,
|
|
@@ -299,12 +373,10 @@ watch(isHydrated, (hydrated) => {
|
|
|
299
373
|
}
|
|
300
374
|
}, { once: true });
|
|
301
375
|
|
|
302
|
-
/* v8 ignore next 2 -- @preserve */
|
|
303
376
|
const hostResizeObserver = typeof window === 'undefined'
|
|
304
377
|
? null
|
|
305
378
|
: new ResizeObserver(updateHostOffset);
|
|
306
379
|
|
|
307
|
-
/* v8 ignore next 2 -- @preserve */
|
|
308
380
|
const itemResizeObserver = typeof window === 'undefined'
|
|
309
381
|
? null
|
|
310
382
|
: new ResizeObserver((entries) => {
|
|
@@ -315,34 +387,32 @@ const itemResizeObserver = typeof window === 'undefined'
|
|
|
315
387
|
const index = Number(target.dataset.index);
|
|
316
388
|
const colIndex = target.dataset.colIndex;
|
|
317
389
|
|
|
390
|
+
let inlineSize = entry.contentRect.width;
|
|
391
|
+
let blockSize = entry.contentRect.height;
|
|
392
|
+
|
|
393
|
+
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
394
|
+
inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
|
|
395
|
+
blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
|
|
396
|
+
} else {
|
|
397
|
+
// Fallback for older browsers or if borderBoxSize is missing
|
|
398
|
+
inlineSize = target.offsetWidth;
|
|
399
|
+
blockSize = target.offsetHeight;
|
|
400
|
+
}
|
|
401
|
+
|
|
318
402
|
if (colIndex !== undefined) {
|
|
319
403
|
// It's a cell measurement. row index is not strictly needed for column width.
|
|
320
404
|
// We use -1 as a placeholder for row index if it's a cell measurement.
|
|
321
|
-
updates.push({ index: -1, inlineSize
|
|
405
|
+
updates.push({ index: -1, inlineSize, blockSize, element: target });
|
|
322
406
|
} else if (!Number.isNaN(index)) {
|
|
323
|
-
let inlineSize = entry.contentRect.width;
|
|
324
|
-
let blockSize = entry.contentRect.height;
|
|
325
|
-
|
|
326
|
-
if (entry.borderBoxSize && entry.borderBoxSize.length > 0) {
|
|
327
|
-
inlineSize = entry.borderBoxSize[ 0 ]!.inlineSize;
|
|
328
|
-
blockSize = entry.borderBoxSize[ 0 ]!.blockSize;
|
|
329
|
-
} else {
|
|
330
|
-
// Fallback for older browsers or if borderBoxSize is missing
|
|
331
|
-
inlineSize = target.offsetWidth;
|
|
332
|
-
blockSize = target.offsetHeight;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
407
|
updates.push({ index, inlineSize, blockSize, element: target });
|
|
336
408
|
}
|
|
337
409
|
}
|
|
338
410
|
|
|
339
|
-
/* v8 ignore else -- @preserve */
|
|
340
411
|
if (updates.length > 0) {
|
|
341
412
|
updateItemSizes(updates);
|
|
342
413
|
}
|
|
343
414
|
});
|
|
344
415
|
|
|
345
|
-
/* v8 ignore next 2 -- @preserve */
|
|
346
416
|
const extraResizeObserver = typeof window === 'undefined'
|
|
347
417
|
? null
|
|
348
418
|
: new ResizeObserver(() => {
|
|
@@ -352,47 +422,28 @@ const extraResizeObserver = typeof window === 'undefined'
|
|
|
352
422
|
});
|
|
353
423
|
|
|
354
424
|
watch(headerRef, (newEl, oldEl) => {
|
|
355
|
-
/* v8 ignore if -- @preserve */
|
|
356
425
|
if (oldEl) {
|
|
357
426
|
extraResizeObserver?.unobserve(oldEl);
|
|
358
427
|
}
|
|
359
428
|
if (newEl) {
|
|
360
429
|
extraResizeObserver?.observe(newEl);
|
|
430
|
+
} else {
|
|
431
|
+
measuredPaddingStart.value = 0;
|
|
361
432
|
}
|
|
362
433
|
}, { immediate: true });
|
|
363
434
|
|
|
364
435
|
watch(footerRef, (newEl, oldEl) => {
|
|
365
|
-
/* v8 ignore if -- @preserve */
|
|
366
436
|
if (oldEl) {
|
|
367
437
|
extraResizeObserver?.unobserve(oldEl);
|
|
368
438
|
}
|
|
369
439
|
if (newEl) {
|
|
370
440
|
extraResizeObserver?.observe(newEl);
|
|
441
|
+
} else {
|
|
442
|
+
measuredPaddingEnd.value = 0;
|
|
371
443
|
}
|
|
372
444
|
}, { immediate: true });
|
|
373
445
|
|
|
374
|
-
const firstRenderedIndex = computed(() => renderedItems.value[ 0 ]?.index);
|
|
375
|
-
watch(firstRenderedIndex, (newIdx, oldIdx) => {
|
|
376
|
-
if (props.direction === 'both') {
|
|
377
|
-
/* v8 ignore else -- @preserve */
|
|
378
|
-
if (oldIdx !== undefined) {
|
|
379
|
-
const oldEl = itemRefs.get(oldIdx);
|
|
380
|
-
if (oldEl) {
|
|
381
|
-
oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
if (newIdx !== undefined) {
|
|
385
|
-
const newEl = itemRefs.get(newIdx);
|
|
386
|
-
/* v8 ignore else -- @preserve */
|
|
387
|
-
if (newEl) {
|
|
388
|
-
newEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}, { flush: 'post' });
|
|
393
|
-
|
|
394
446
|
onMounted(() => {
|
|
395
|
-
/* v8 ignore else -- @preserve */
|
|
396
447
|
if (hostRef.value) {
|
|
397
448
|
hostResizeObserver?.observe(hostRef.value);
|
|
398
449
|
}
|
|
@@ -400,14 +451,7 @@ onMounted(() => {
|
|
|
400
451
|
// Re-observe items that were set before observer was ready
|
|
401
452
|
for (const el of itemRefs.values()) {
|
|
402
453
|
itemResizeObserver?.observe(el);
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
// Observe cells of the first rendered item
|
|
406
|
-
/* v8 ignore else -- @preserve */
|
|
407
|
-
if (firstRenderedIndex.value !== undefined) {
|
|
408
|
-
const el = itemRefs.get(firstRenderedIndex.value);
|
|
409
|
-
/* v8 ignore else -- @preserve */
|
|
410
|
-
if (el) {
|
|
454
|
+
if (props.direction === 'both') {
|
|
411
455
|
el.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
412
456
|
}
|
|
413
457
|
}
|
|
@@ -422,81 +466,394 @@ watch([ hostRef, wrapperRef ], ([ newHost ], [ oldHost ]) => {
|
|
|
422
466
|
}
|
|
423
467
|
});
|
|
424
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
|
+
*/
|
|
425
485
|
function setItemRef(el: unknown, index: number) {
|
|
426
486
|
if (el) {
|
|
427
487
|
itemRefs.set(index, el as HTMLElement);
|
|
428
488
|
itemResizeObserver?.observe(el as HTMLElement);
|
|
489
|
+
|
|
490
|
+
if (props.direction === 'both') {
|
|
491
|
+
(el as HTMLElement).querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.observe(c));
|
|
492
|
+
}
|
|
429
493
|
} else {
|
|
430
494
|
const oldEl = itemRefs.get(index);
|
|
431
|
-
/* v8 ignore else -- @preserve */
|
|
432
495
|
if (oldEl) {
|
|
433
496
|
itemResizeObserver?.unobserve(oldEl);
|
|
497
|
+
if (props.direction === 'both') {
|
|
498
|
+
oldEl.querySelectorAll('[data-col-index]').forEach((c) => itemResizeObserver?.unobserve(c));
|
|
499
|
+
}
|
|
434
500
|
itemRefs.delete(index);
|
|
435
501
|
}
|
|
436
502
|
}
|
|
437
503
|
}
|
|
438
504
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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;
|
|
444
520
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
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;
|
|
454
529
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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);
|
|
461
544
|
} else {
|
|
462
|
-
|
|
545
|
+
stopInertia();
|
|
463
546
|
}
|
|
464
|
-
|
|
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;
|
|
465
559
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
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) {
|
|
469
573
|
return;
|
|
470
574
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
575
|
+
|
|
576
|
+
// Only handle primary button or touch
|
|
577
|
+
if (event.pointerType === 'mouse' && event.button !== 0) {
|
|
474
578
|
return;
|
|
475
579
|
}
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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) {
|
|
479
600
|
return;
|
|
480
601
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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;
|
|
485
614
|
}
|
|
486
|
-
|
|
487
|
-
|
|
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(() => {
|
|
488
623
|
scrollToOffset(
|
|
489
|
-
|
|
490
|
-
|
|
624
|
+
startScrollOffset.x + deltaX,
|
|
625
|
+
startScrollOffset.y + deltaY,
|
|
626
|
+
{ behavior: 'auto' },
|
|
491
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) {
|
|
492
638
|
return;
|
|
493
639
|
}
|
|
494
|
-
|
|
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
|
|
495
668
|
event.preventDefault();
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
+
*/
|
|
691
|
+
function handleKeyDown(event: KeyboardEvent) {
|
|
692
|
+
const { viewportSize, scrollOffset } = scrollDetails.value;
|
|
693
|
+
const isHorizontal = props.direction !== 'vertical';
|
|
694
|
+
const isVertical = props.direction !== 'horizontal';
|
|
695
|
+
|
|
696
|
+
const sStart = virtualScrollProps.value.stickyStart as { x: number; y: number; };
|
|
697
|
+
const sEnd = virtualScrollProps.value.stickyEnd as { x: number; y: number; };
|
|
698
|
+
|
|
699
|
+
switch (event.key) {
|
|
700
|
+
case 'Home': {
|
|
701
|
+
event.preventDefault();
|
|
702
|
+
stopProgrammaticScroll();
|
|
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' });
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
case 'End': {
|
|
711
|
+
event.preventDefault();
|
|
712
|
+
stopProgrammaticScroll();
|
|
713
|
+
const lastItemIndex = props.items.length - 1;
|
|
714
|
+
const lastColIndex = (props.columnCount || 0) > 0 ? props.columnCount - 1 : 0;
|
|
715
|
+
|
|
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' });
|
|
726
|
+
} else {
|
|
727
|
+
scrollToIndex(
|
|
728
|
+
props.direction === 'vertical' ? lastItemIndex : 0,
|
|
729
|
+
props.direction === 'horizontal' ? lastItemIndex : 0,
|
|
730
|
+
{ behavior, align: 'end' },
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
case 'ArrowUp': {
|
|
736
|
+
event.preventDefault();
|
|
737
|
+
stopProgrammaticScroll();
|
|
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
|
+
}
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
case 'ArrowDown': {
|
|
754
|
+
event.preventDefault();
|
|
755
|
+
stopProgrammaticScroll();
|
|
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
|
+
}
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
case 'ArrowLeft': {
|
|
772
|
+
event.preventDefault();
|
|
773
|
+
stopProgrammaticScroll();
|
|
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
|
+
}
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
case 'ArrowRight': {
|
|
807
|
+
event.preventDefault();
|
|
808
|
+
stopProgrammaticScroll();
|
|
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
|
+
}
|
|
839
|
+
break;
|
|
840
|
+
}
|
|
841
|
+
case 'PageUp':
|
|
842
|
+
event.preventDefault();
|
|
843
|
+
stopProgrammaticScroll();
|
|
844
|
+
scrollToOffset(
|
|
845
|
+
!isVertical && isHorizontal ? scrollOffset.x - viewportSize.width : null,
|
|
846
|
+
isVertical ? scrollOffset.y - viewportSize.height : null,
|
|
847
|
+
);
|
|
848
|
+
break;
|
|
849
|
+
case 'PageDown':
|
|
850
|
+
event.preventDefault();
|
|
851
|
+
stopProgrammaticScroll();
|
|
852
|
+
scrollToOffset(
|
|
853
|
+
!isVertical && isHorizontal ? scrollOffset.x + viewportSize.width : null,
|
|
854
|
+
isVertical ? scrollOffset.y + viewportSize.height : null,
|
|
855
|
+
);
|
|
856
|
+
break;
|
|
500
857
|
}
|
|
501
858
|
}
|
|
502
859
|
|
|
@@ -506,99 +863,155 @@ onUnmounted(() => {
|
|
|
506
863
|
extraResizeObserver?.disconnect();
|
|
507
864
|
});
|
|
508
865
|
|
|
509
|
-
const
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// window
|
|
514
|
-
|| (typeof window !== 'undefined' && c === window)
|
|
515
|
-
) {
|
|
516
|
-
return true;
|
|
517
|
-
}
|
|
866
|
+
const containerStyle = computed(() => {
|
|
867
|
+
const base: Record<string, string | number | undefined> = {
|
|
868
|
+
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
869
|
+
};
|
|
518
870
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
return (c as HTMLElement).tagName === 'BODY';
|
|
871
|
+
if (showVirtualScrollbars.value) {
|
|
872
|
+
base.overflow = 'auto';
|
|
522
873
|
}
|
|
523
874
|
|
|
524
|
-
|
|
525
|
-
|
|
875
|
+
if (useVirtualScrolling.value) {
|
|
876
|
+
base.touchAction = 'none';
|
|
877
|
+
}
|
|
526
878
|
|
|
527
|
-
const containerStyle = computed(() => {
|
|
528
879
|
if (isWindowContainer.value) {
|
|
529
|
-
return
|
|
530
|
-
...(props.direction !== 'vertical' ? { whiteSpace: 'nowrap' as const } : {}),
|
|
531
|
-
};
|
|
880
|
+
return base;
|
|
532
881
|
}
|
|
533
882
|
|
|
534
883
|
if (props.containerTag === 'table') {
|
|
535
884
|
return {
|
|
885
|
+
...base,
|
|
536
886
|
minInlineSize: props.direction === 'vertical' ? '100%' : 'auto',
|
|
537
887
|
};
|
|
538
888
|
}
|
|
539
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
|
+
|
|
540
912
|
return {
|
|
541
|
-
|
|
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,
|
|
542
921
|
};
|
|
543
922
|
});
|
|
544
923
|
|
|
545
|
-
const
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
}
|
|
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
|
+
}
|
|
549
932
|
|
|
550
|
-
const
|
|
551
|
-
|
|
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
|
+
};
|
|
552
942
|
|
|
553
943
|
return {
|
|
554
|
-
|
|
555
|
-
|
|
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,
|
|
556
952
|
};
|
|
557
953
|
});
|
|
558
954
|
|
|
559
|
-
const
|
|
560
|
-
inlineSize: props.direction === 'vertical' ? '1px' : `${ totalWidth.value }px`,
|
|
561
|
-
blockSize: props.direction === 'horizontal' ? '1px' : `${ totalHeight.value }px`,
|
|
562
|
-
}));
|
|
563
|
-
|
|
564
|
-
function getItemStyle(item: RenderedItem<T>) {
|
|
565
|
-
const isVertical = props.direction === 'vertical';
|
|
955
|
+
const wrapperStyle = computed(() => {
|
|
566
956
|
const isHorizontal = props.direction === 'horizontal';
|
|
957
|
+
const isVertical = props.direction === 'vertical';
|
|
567
958
|
const isBoth = props.direction === 'both';
|
|
568
|
-
const isDynamic = props.itemSize === undefined || props.itemSize === null || props.itemSize === 0;
|
|
569
959
|
|
|
570
960
|
const style: Record<string, string | number | undefined> = {
|
|
571
|
-
|
|
961
|
+
inlineSize: isVertical ? '100%' : `${ renderedVirtualWidth.value }px`,
|
|
962
|
+
blockSize: isHorizontal ? '100%' : `${ renderedVirtualHeight.value }px`,
|
|
572
963
|
};
|
|
573
964
|
|
|
574
|
-
if (
|
|
575
|
-
style.
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (isDynamic) {
|
|
581
|
-
if (!isVertical) {
|
|
582
|
-
style.minInlineSize = '1px';
|
|
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`;
|
|
583
970
|
}
|
|
584
|
-
if (
|
|
585
|
-
style.
|
|
971
|
+
if ((isVertical || isBoth) && props.gap) {
|
|
972
|
+
style.rowGap = `${ props.gap }px`;
|
|
586
973
|
}
|
|
587
974
|
}
|
|
588
975
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if (isVertical || isBoth) {
|
|
592
|
-
style.insetBlockStart = `${ getPaddingY(props.scrollPaddingStart, props.direction) }px`;
|
|
593
|
-
}
|
|
976
|
+
return style;
|
|
977
|
+
});
|
|
594
978
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
}
|
|
979
|
+
const loadingStyle = computed(() => {
|
|
980
|
+
const isHorizontal = props.direction === 'horizontal';
|
|
598
981
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
982
|
+
return {
|
|
983
|
+
display: isHorizontal ? 'inline-block' : 'block',
|
|
984
|
+
...(isHorizontal ? { blockSize: '100%', verticalAlign: 'top' } : { inlineSize: '100%' }),
|
|
985
|
+
};
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
const spacerStyle = computed(() => ({
|
|
989
|
+
inlineSize: props.direction === 'vertical' ? '1px' : `${ renderedVirtualWidth.value }px`,
|
|
990
|
+
blockSize: props.direction === 'horizontal' ? '1px' : `${ renderedVirtualHeight.value }px`,
|
|
991
|
+
}));
|
|
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
|
+
*/
|
|
999
|
+
function getItemStyle(item: RenderedItem<T>) {
|
|
1000
|
+
const style = calculateItemStyle({
|
|
1001
|
+
containerTag: props.containerTag,
|
|
1002
|
+
direction: props.direction,
|
|
1003
|
+
isHydrated: isHydrated.value,
|
|
1004
|
+
item,
|
|
1005
|
+
itemSize: props.itemSize,
|
|
1006
|
+
paddingStartX: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).x,
|
|
1007
|
+
paddingStartY: (virtualScrollProps.value.scrollPaddingStart as { x: number; y: number; }).y,
|
|
1008
|
+
isRtl: isRtl.value,
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
if (!isHydrated.value && props.direction === 'both') {
|
|
1012
|
+
style.display = 'flex';
|
|
1013
|
+
if (props.columnGap) {
|
|
1014
|
+
style.columnGap = `${ props.columnGap }px`;
|
|
602
1015
|
}
|
|
603
1016
|
}
|
|
604
1017
|
|
|
@@ -611,19 +1024,159 @@ const headerTag = computed(() => isTable.value ? 'thead' : 'div');
|
|
|
611
1024
|
const footerTag = computed(() => isTable.value ? 'tfoot' : 'div');
|
|
612
1025
|
|
|
613
1026
|
defineExpose({
|
|
1027
|
+
...toRefs(props),
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Detailed information about the current scroll state.
|
|
1031
|
+
* @see ScrollDetails
|
|
1032
|
+
* @see useVirtualScroll
|
|
1033
|
+
*/
|
|
614
1034
|
scrollDetails,
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* Information about the current visible range of columns.
|
|
1038
|
+
* @see ColumnRange
|
|
1039
|
+
* @see useVirtualScroll
|
|
1040
|
+
*/
|
|
615
1041
|
columnRange,
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Helper to get the width of a specific column.
|
|
1045
|
+
* @param index - The column index.
|
|
1046
|
+
* @see useVirtualScroll
|
|
1047
|
+
*/
|
|
616
1048
|
getColumnWidth,
|
|
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
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Programmatically scroll to a specific row and/or column.
|
|
1087
|
+
*
|
|
1088
|
+
* @param rowIndex - The row index to scroll to. Pass null to only scroll horizontally.
|
|
1089
|
+
* @param colIndex - The column index to scroll to. Pass null to only scroll vertically.
|
|
1090
|
+
* @param options - Alignment and behavior options. Defaults to { align: 'auto', behavior: 'auto' }.
|
|
1091
|
+
* @see ScrollAlignment
|
|
1092
|
+
* @see ScrollToIndexOptions
|
|
1093
|
+
* @see useVirtualScroll
|
|
1094
|
+
*/
|
|
617
1095
|
scrollToIndex,
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* Programmatically scroll to a specific pixel offset.
|
|
1099
|
+
*
|
|
1100
|
+
* @param x - The pixel offset to scroll to on the X axis. Pass null to keep current position.
|
|
1101
|
+
* @param y - The pixel offset to scroll to on the Y axis. Pass null to keep current position.
|
|
1102
|
+
* @param options - Scroll options (behavior). Defaults to { behavior: 'auto' }.
|
|
1103
|
+
* @see useVirtualScroll
|
|
1104
|
+
*/
|
|
618
1105
|
scrollToOffset,
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* Resets all dynamic measurements and re-initializes from props.
|
|
1109
|
+
* @see useVirtualScroll
|
|
1110
|
+
*/
|
|
619
1111
|
refresh,
|
|
620
|
-
|
|
1112
|
+
|
|
1113
|
+
/**
|
|
1114
|
+
* Immediately stops any currently active smooth scroll animation and clears pending corrections.
|
|
1115
|
+
* @see useVirtualScroll
|
|
1116
|
+
*/
|
|
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,
|
|
621
1173
|
});
|
|
622
1174
|
</script>
|
|
623
1175
|
|
|
624
1176
|
<template>
|
|
625
1177
|
<component
|
|
626
1178
|
:is="containerTag"
|
|
1179
|
+
:id="containerId"
|
|
627
1180
|
ref="hostRef"
|
|
628
1181
|
class="virtual-scroll-container"
|
|
629
1182
|
:class="[
|
|
@@ -632,16 +1185,37 @@ defineExpose({
|
|
|
632
1185
|
'virtual-scroll--hydrated': isHydrated,
|
|
633
1186
|
'virtual-scroll--window': isWindowContainer,
|
|
634
1187
|
'virtual-scroll--table': isTable,
|
|
1188
|
+
'virtual-scroll--hide-scrollbar': showVirtualScrollbars,
|
|
635
1189
|
},
|
|
636
1190
|
]"
|
|
637
1191
|
:style="containerStyle"
|
|
638
1192
|
tabindex="0"
|
|
639
1193
|
@keydown="handleKeyDown"
|
|
640
|
-
@
|
|
641
|
-
@
|
|
642
|
-
@
|
|
1194
|
+
@pointerdown="handlePointerDown"
|
|
1195
|
+
@pointermove="handlePointerMove"
|
|
1196
|
+
@pointerup="handlePointerUp"
|
|
1197
|
+
@pointercancel="handlePointerUp"
|
|
643
1198
|
>
|
|
644
|
-
|
|
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
|
+
|
|
645
1219
|
<component
|
|
646
1220
|
:is="headerTag"
|
|
647
1221
|
v-if="slots.header"
|
|
@@ -651,7 +1225,6 @@ defineExpose({
|
|
|
651
1225
|
>
|
|
652
1226
|
<slot name="header" />
|
|
653
1227
|
</component>
|
|
654
|
-
<!-- v8 ignore stop -->
|
|
655
1228
|
|
|
656
1229
|
<component
|
|
657
1230
|
:is="wrapperTag"
|
|
@@ -660,7 +1233,6 @@ defineExpose({
|
|
|
660
1233
|
:style="wrapperStyle"
|
|
661
1234
|
>
|
|
662
1235
|
<!-- Phantom element to push scroll height -->
|
|
663
|
-
<!-- v8 ignore start -->
|
|
664
1236
|
<component
|
|
665
1237
|
:is="itemTag"
|
|
666
1238
|
v-if="isTable"
|
|
@@ -669,7 +1241,6 @@ defineExpose({
|
|
|
669
1241
|
>
|
|
670
1242
|
<td style="padding: 0; border: none; block-size: inherit;" />
|
|
671
1243
|
</component>
|
|
672
|
-
<!-- v8 ignore stop -->
|
|
673
1244
|
|
|
674
1245
|
<component
|
|
675
1246
|
:is="itemTag"
|
|
@@ -688,8 +1259,10 @@ defineExpose({
|
|
|
688
1259
|
name="item"
|
|
689
1260
|
:item="renderedItem.item"
|
|
690
1261
|
:index="renderedItem.index"
|
|
691
|
-
:column-range="
|
|
1262
|
+
:column-range="slotColumnRange"
|
|
692
1263
|
:get-column-width="getColumnWidth"
|
|
1264
|
+
:gap="props.gap"
|
|
1265
|
+
:column-gap="props.columnGap"
|
|
693
1266
|
:is-sticky="renderedItem.isSticky"
|
|
694
1267
|
:is-sticky-active="renderedItem.isStickyActive"
|
|
695
1268
|
/>
|
|
@@ -699,7 +1272,6 @@ defineExpose({
|
|
|
699
1272
|
</component>
|
|
700
1273
|
</component>
|
|
701
1274
|
|
|
702
|
-
<!-- v8 ignore start -->
|
|
703
1275
|
<div
|
|
704
1276
|
v-if="loading && slots.loading"
|
|
705
1277
|
class="virtual-scroll-loading"
|
|
@@ -717,113 +1289,143 @@ defineExpose({
|
|
|
717
1289
|
>
|
|
718
1290
|
<slot name="footer" />
|
|
719
1291
|
</component>
|
|
720
|
-
<!-- v8 ignore stop -->
|
|
721
1292
|
</component>
|
|
722
1293
|
</template>
|
|
723
1294
|
|
|
724
1295
|
<style scoped>
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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
|
+
}
|
|
730
1307
|
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
}
|
|
1308
|
+
&.virtual-scroll--table {
|
|
1309
|
+
display: block;
|
|
1310
|
+
}
|
|
735
1311
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
}
|
|
1312
|
+
&.virtual-scroll--hide-scrollbar {
|
|
1313
|
+
scrollbar-width: none;
|
|
1314
|
+
-ms-overflow-style: none;
|
|
740
1315
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
}
|
|
1316
|
+
&::-webkit-scrollbar {
|
|
1317
|
+
display: none;
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
744
1320
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
1321
|
+
&.virtual-scroll--horizontal,
|
|
1322
|
+
&.virtual-scroll--both {
|
|
1323
|
+
white-space: nowrap;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
748
1326
|
|
|
749
|
-
|
|
750
|
-
position:
|
|
1327
|
+
.virtual-scroll-scrollbar-container {
|
|
1328
|
+
position: sticky;
|
|
751
1329
|
inset-block-start: 0;
|
|
752
1330
|
inset-inline-start: 0;
|
|
1331
|
+
inline-size: 100%;
|
|
1332
|
+
block-size: 0;
|
|
1333
|
+
z-index: 30;
|
|
1334
|
+
pointer-events: none;
|
|
1335
|
+
overflow: visible;
|
|
753
1336
|
}
|
|
754
|
-
}
|
|
755
1337
|
|
|
756
|
-
.virtual-scroll-
|
|
757
|
-
|
|
758
|
-
|
|
1338
|
+
.virtual-scroll-scrollbar-viewport {
|
|
1339
|
+
position: absolute;
|
|
1340
|
+
inset-block-start: 0;
|
|
1341
|
+
inset-inline-start: 0;
|
|
1342
|
+
pointer-events: none;
|
|
1343
|
+
}
|
|
759
1344
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1345
|
+
.virtual-scroll-wrapper {
|
|
1346
|
+
contain: layout;
|
|
1347
|
+
position: relative;
|
|
763
1348
|
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1349
|
+
:where(.virtual-scroll--hydrated > & > .virtual-scroll-item) {
|
|
1350
|
+
position: absolute;
|
|
1351
|
+
inset-block-start: 0;
|
|
1352
|
+
inset-inline-start: 0;
|
|
767
1353
|
}
|
|
768
1354
|
}
|
|
769
|
-
}
|
|
770
1355
|
|
|
771
|
-
.virtual-scroll-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
background: rgba(0, 0, 0, 0.7);
|
|
776
|
-
color: white;
|
|
777
|
-
font-size: 10px;
|
|
778
|
-
padding: 2px 4px;
|
|
779
|
-
border-radius: 4px;
|
|
780
|
-
pointer-events: none;
|
|
781
|
-
z-index: 100;
|
|
782
|
-
font-family: monospace;
|
|
783
|
-
}
|
|
1356
|
+
.virtual-scroll-item {
|
|
1357
|
+
display: grid;
|
|
1358
|
+
box-sizing: border-box;
|
|
1359
|
+
will-change: transform;
|
|
784
1360
|
|
|
785
|
-
.virtual-scroll
|
|
786
|
-
|
|
787
|
-
|
|
1361
|
+
&:where(.virtual-scroll--debug) {
|
|
1362
|
+
outline: 1px dashed rgba(255, 0, 0, 0.5);
|
|
1363
|
+
background-color: rgba(255, 0, 0, 0.05);
|
|
788
1364
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
1365
|
+
&:where(:hover) {
|
|
1366
|
+
background-color: rgba(255, 0, 0, 0.1);
|
|
1367
|
+
z-index: 100;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
794
1371
|
|
|
795
|
-
.virtual-scroll
|
|
796
|
-
|
|
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
|
+
}
|
|
797
1385
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
inset-inline-start: 0;
|
|
801
|
-
min-inline-size: 100%;
|
|
802
|
-
box-sizing: border-box;
|
|
1386
|
+
.virtual-scroll-spacer {
|
|
1387
|
+
pointer-events: none;
|
|
803
1388
|
}
|
|
804
1389
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
box-sizing: border-box;
|
|
1390
|
+
.virtual-scroll-header,
|
|
1391
|
+
.virtual-scroll-footer {
|
|
1392
|
+
position: relative;
|
|
1393
|
+
z-index: 20;
|
|
810
1394
|
}
|
|
811
1395
|
|
|
812
|
-
|
|
813
|
-
|
|
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
|
+
}
|
|
814
1416
|
}
|
|
815
|
-
}
|
|
816
1417
|
|
|
817
|
-
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
818
|
-
display: inline-flex;
|
|
819
|
-
min-inline-size: 100%;
|
|
820
|
-
& > :deep(tr) {
|
|
1418
|
+
:is(tbody.virtual-scroll-wrapper, thead.virtual-scroll-header, tfoot.virtual-scroll-footer) {
|
|
821
1419
|
display: inline-flex;
|
|
822
1420
|
min-inline-size: 100%;
|
|
1421
|
+
& > :deep(tr) {
|
|
1422
|
+
display: inline-flex;
|
|
1423
|
+
min-inline-size: 100%;
|
|
823
1424
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1425
|
+
& > :is(td, th) {
|
|
1426
|
+
display: inline-block;
|
|
1427
|
+
align-items: center;
|
|
1428
|
+
}
|
|
827
1429
|
}
|
|
828
1430
|
}
|
|
829
1431
|
}
|