@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
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { ScrollAxis } from '../types';
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue';
|
|
3
|
+
|
|
4
|
+
import { computed, getCurrentInstance, onUnmounted, ref, toValue } from 'vue';
|
|
5
|
+
|
|
6
|
+
/** Configuration properties for the `useVirtualScrollbar` composable. */
|
|
7
|
+
export interface UseVirtualScrollbarProps {
|
|
8
|
+
/** The axis for this scrollbar. */
|
|
9
|
+
axis: MaybeRefOrGetter<ScrollAxis>;
|
|
10
|
+
/** Total size of the scrollable content area in display pixels (DU). */
|
|
11
|
+
totalSize: MaybeRefOrGetter<number>;
|
|
12
|
+
/** Current scroll position in display pixels (DU). */
|
|
13
|
+
position: MaybeRefOrGetter<number>;
|
|
14
|
+
/** Viewport size in display pixels (DU). */
|
|
15
|
+
viewportSize: MaybeRefOrGetter<number>;
|
|
16
|
+
/**
|
|
17
|
+
* Function to scroll to a specific display pixel offset (DU) on this axis.
|
|
18
|
+
* @param offset - The display pixel offset to scroll to.
|
|
19
|
+
*/
|
|
20
|
+
scrollToOffset: (offset: number) => void;
|
|
21
|
+
/** The ID of the container element this scrollbar controls. */
|
|
22
|
+
containerId?: MaybeRefOrGetter<string | undefined>;
|
|
23
|
+
/** Whether the scrollbar is in Right-to-Left (RTL) mode. */
|
|
24
|
+
isRtl?: MaybeRefOrGetter<boolean>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Composable for virtual scrollbar logic.
|
|
29
|
+
* Provides attributes and event listeners for track and thumb elements.
|
|
30
|
+
*
|
|
31
|
+
* @param props - Configuration properties.
|
|
32
|
+
*/
|
|
33
|
+
export function useVirtualScrollbar(props: UseVirtualScrollbarProps) {
|
|
34
|
+
const axis = computed(() => toValue(props.axis));
|
|
35
|
+
const totalSize = computed(() => toValue(props.totalSize));
|
|
36
|
+
const position = computed(() => toValue(props.position));
|
|
37
|
+
const viewportSize = computed(() => toValue(props.viewportSize));
|
|
38
|
+
const containerId = computed(() => toValue(props.containerId));
|
|
39
|
+
const isRtl = computed(() => !!toValue(props.isRtl));
|
|
40
|
+
|
|
41
|
+
const isHorizontal = computed(() => axis.value === 'horizontal');
|
|
42
|
+
|
|
43
|
+
const viewportPercent = computed(() => {
|
|
44
|
+
if (totalSize.value <= 0) {
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
return Math.min(1, viewportSize.value / totalSize.value);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const positionPercent = computed(() => {
|
|
51
|
+
const scrollableRange = totalSize.value - viewportSize.value;
|
|
52
|
+
if (scrollableRange <= 0) {
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
return Math.max(0, Math.min(1, position.value / scrollableRange));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const thumbSizePercent = computed(() => {
|
|
59
|
+
// Minimum thumb size in pixels (32px for better touch targets and visibility)
|
|
60
|
+
const minThumbSize = 32;
|
|
61
|
+
const minPercent = viewportSize.value > 0 ? (minThumbSize / viewportSize.value) : 0.1;
|
|
62
|
+
return Math.max(Math.min(minPercent, 0.1), viewportPercent.value) * 100;
|
|
63
|
+
});
|
|
64
|
+
/** Calculated thumb position as a percentage of the track size (0 to 100). */
|
|
65
|
+
const thumbPositionPercent = computed(() => positionPercent.value * (100 - thumbSizePercent.value));
|
|
66
|
+
|
|
67
|
+
/** Reactive style object for the scrollbar thumb. */
|
|
68
|
+
const thumbStyle = computed(() => {
|
|
69
|
+
if (isHorizontal.value) {
|
|
70
|
+
return {
|
|
71
|
+
inlineSize: `${ thumbSizePercent.value }%`,
|
|
72
|
+
insetInlineStart: `${ thumbPositionPercent.value }%`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
blockSize: `${ thumbSizePercent.value }%`,
|
|
77
|
+
insetBlockStart: `${ thumbPositionPercent.value }%`,
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Reactive style object for the scrollbar track. */
|
|
82
|
+
const trackStyle = computed(() => {
|
|
83
|
+
const displayViewportSize = viewportSize.value;
|
|
84
|
+
const scrollbarGap = 'var(--vs-scrollbar-has-cross-gap, var(--vsi-scrollbar-has-cross-gap, 0)) * var(--vs-scrollbar-cross-gap, var(--vsi-scrollbar-size, 8px))';
|
|
85
|
+
|
|
86
|
+
return isHorizontal.value
|
|
87
|
+
? {
|
|
88
|
+
inlineSize: `calc(${ Math.max(0, displayViewportSize - 4) }px - ${ scrollbarGap })`,
|
|
89
|
+
}
|
|
90
|
+
: {
|
|
91
|
+
blockSize: `calc(${ Math.max(0, displayViewportSize - 4) }px - ${ scrollbarGap })`,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const isDragging = ref(false);
|
|
96
|
+
let startPos = 0;
|
|
97
|
+
let startScrollPos = 0;
|
|
98
|
+
|
|
99
|
+
function handleTrackClick(event: MouseEvent) {
|
|
100
|
+
const track = event.currentTarget as HTMLElement;
|
|
101
|
+
if (event.target !== track) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rect = track.getBoundingClientRect();
|
|
106
|
+
const trackSize = isHorizontal.value ? rect.width : rect.height;
|
|
107
|
+
let clickPos = 0;
|
|
108
|
+
|
|
109
|
+
if (isHorizontal.value) {
|
|
110
|
+
clickPos = isRtl.value ? rect.right - event.clientX : event.clientX - rect.left;
|
|
111
|
+
} else {
|
|
112
|
+
clickPos = event.clientY - rect.top;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const thumbSize = (thumbSizePercent.value / 100) * trackSize;
|
|
116
|
+
const targetPercent = (clickPos - thumbSize / 2) / (trackSize - thumbSize);
|
|
117
|
+
const scrollableRange = totalSize.value - viewportSize.value;
|
|
118
|
+
|
|
119
|
+
let targetOffset = targetPercent * scrollableRange;
|
|
120
|
+
if (targetOffset > scrollableRange - 1) {
|
|
121
|
+
targetOffset = scrollableRange;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
props.scrollToOffset(Math.max(0, Math.min(scrollableRange, targetOffset)));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function handleThumbPointerDown(event: PointerEvent) {
|
|
128
|
+
isDragging.value = true;
|
|
129
|
+
startPos = isHorizontal.value
|
|
130
|
+
? (isRtl.value ? -event.clientX : event.clientX)
|
|
131
|
+
: event.clientY;
|
|
132
|
+
startScrollPos = position.value;
|
|
133
|
+
|
|
134
|
+
const thumb = event.currentTarget as HTMLElement;
|
|
135
|
+
thumb.setPointerCapture(event.pointerId);
|
|
136
|
+
event.preventDefault();
|
|
137
|
+
event.stopPropagation();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function handleThumbPointerMove(event: PointerEvent) {
|
|
141
|
+
if (!isDragging.value) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const thumb = event.currentTarget as HTMLElement;
|
|
146
|
+
const track = thumb.parentElement;
|
|
147
|
+
if (!track) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const currentPos = isHorizontal.value
|
|
152
|
+
? (isRtl.value ? -event.clientX : event.clientX)
|
|
153
|
+
: event.clientY;
|
|
154
|
+
const delta = currentPos - startPos;
|
|
155
|
+
const rect = track.getBoundingClientRect();
|
|
156
|
+
const trackSize = isHorizontal.value ? rect.width : rect.height;
|
|
157
|
+
const thumbSize = (thumbSizePercent.value / 100) * trackSize;
|
|
158
|
+
|
|
159
|
+
const scrollableTrackRange = trackSize - thumbSize;
|
|
160
|
+
if (scrollableTrackRange <= 0) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const scrollableContentRange = totalSize.value - viewportSize.value;
|
|
165
|
+
let targetOffset = startScrollPos + (delta / scrollableTrackRange) * scrollableContentRange;
|
|
166
|
+
|
|
167
|
+
if (targetOffset > scrollableContentRange - 1) {
|
|
168
|
+
targetOffset = scrollableContentRange;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
props.scrollToOffset(Math.max(0, Math.min(scrollableContentRange, targetOffset)));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function handleThumbPointerUp(event: PointerEvent) {
|
|
175
|
+
if (!isDragging.value) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
isDragging.value = false;
|
|
179
|
+
(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (getCurrentInstance()) {
|
|
183
|
+
onUnmounted(() => {
|
|
184
|
+
isDragging.value = false;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const trackProps = computed(() => ({
|
|
189
|
+
class: [
|
|
190
|
+
'virtual-scrollbar-track',
|
|
191
|
+
`virtual-scrollbar-track--${ isHorizontal.value ? 'horizontal' : 'vertical' }`,
|
|
192
|
+
],
|
|
193
|
+
style: trackStyle.value,
|
|
194
|
+
role: 'scrollbar',
|
|
195
|
+
'aria-orientation': axis.value,
|
|
196
|
+
'aria-valuenow': Math.round(position.value),
|
|
197
|
+
'aria-valuemin': 0,
|
|
198
|
+
'aria-valuemax': Math.round(totalSize.value - viewportSize.value),
|
|
199
|
+
'aria-controls': containerId.value,
|
|
200
|
+
tabindex: -1,
|
|
201
|
+
onMousedown: handleTrackClick,
|
|
202
|
+
}));
|
|
203
|
+
|
|
204
|
+
const thumbProps = computed(() => ({
|
|
205
|
+
class: [
|
|
206
|
+
'virtual-scrollbar-thumb',
|
|
207
|
+
`virtual-scrollbar-thumb--${ isHorizontal.value ? 'horizontal' : 'vertical' }`,
|
|
208
|
+
{
|
|
209
|
+
'virtual-scrollbar-thumb--active': isDragging.value,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
style: thumbStyle.value,
|
|
213
|
+
onPointerdown: handleThumbPointerDown,
|
|
214
|
+
onPointermove: handleThumbPointerMove,
|
|
215
|
+
onPointerup: handleThumbPointerUp,
|
|
216
|
+
onPointercancel: handleThumbPointerUp,
|
|
217
|
+
}));
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
/** Viewport size as a percentage of total size (0 to 1). */
|
|
221
|
+
viewportPercent,
|
|
222
|
+
/** Current scroll position as a percentage of the scrollable range (0 to 1). */
|
|
223
|
+
positionPercent,
|
|
224
|
+
/** Calculated thumb size as a percentage of the track size (0 to 100). */
|
|
225
|
+
thumbSizePercent,
|
|
226
|
+
/** Calculated thumb position as a percentage of the track size (0 to 100). */
|
|
227
|
+
thumbPositionPercent,
|
|
228
|
+
/** Reactive style object for the scrollbar track. */
|
|
229
|
+
trackStyle,
|
|
230
|
+
/** Reactive style object for the scrollbar thumb. */
|
|
231
|
+
thumbStyle,
|
|
232
|
+
/** Attributes and event listeners to be bound to the track element. */
|
|
233
|
+
trackProps,
|
|
234
|
+
/** Attributes and event listeners to be bound to the thumb element. */
|
|
235
|
+
thumbProps,
|
|
236
|
+
/** Whether the thumb is currently being dragged. */
|
|
237
|
+
isDragging,
|
|
238
|
+
};
|
|
239
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
export { default as VirtualScroll } from './components/VirtualScroll.vue';
|
|
2
|
+
export { default as VirtualScrollbar } from './components/VirtualScrollbar.vue';
|
|
2
3
|
export * from './composables/useVirtualScroll';
|
|
4
|
+
export * from './composables/useVirtualScrollbar';
|
|
5
|
+
export * from './types';
|
|
3
6
|
export * from './utils/fenwick-tree';
|
|
4
7
|
export * from './utils/scroll';
|
|
8
|
+
export * from './utils/virtual-scroll-logic';
|