@pdanpdan/virtual-scroll 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +292 -0
- package/dist/index.css +1 -0
- package/dist/index.js +961 -0
- package/dist/index.js.map +1 -0
- package/package.json +59 -0
- package/src/components/VirtualScroll.test.ts +912 -0
- package/src/components/VirtualScroll.vue +748 -0
- package/src/composables/useVirtualScroll.test.ts +1214 -0
- package/src/composables/useVirtualScroll.ts +1407 -0
- package/src/index.ts +4 -0
- package/src/utils/fenwick-tree.test.ts +119 -0
- package/src/utils/fenwick-tree.ts +155 -0
- package/src/utils/scroll.ts +59 -0
package/README.md
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# @pdanpdan/virtual-scroll
|
|
2
|
+
|
|
3
|
+
A high-performance, flexible virtual scrolling component for Vue 3.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **High Performance**: Optimized for large lists using Fenwick Tree (_O(log n)_) for offset calculations.
|
|
8
|
+
- **Dynamic & Fixed Sizes**: Supports both uniform item sizes and variable sizes via `ResizeObserver`.
|
|
9
|
+
- **Multi-Directional**: Works in `vertical`, `horizontal`, or `both` (grid) directions.
|
|
10
|
+
- **Container Flexibility**: Can use a custom element or the browser `window`/`body` as the scroll container.
|
|
11
|
+
- **SSR Support**: Built-in support for pre-rendering specific ranges for Server-Side Rendering.
|
|
12
|
+
- **Feature Rich**: Supports infinite scroll, loading states, sticky sections, headers, footers, buffers, and programmatic scrolling.
|
|
13
|
+
- **Scroll Restoration**: Automatically maintains scroll position when items are prepended to the list.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @pdanpdan/virtual-scroll
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage Modes
|
|
22
|
+
|
|
23
|
+
The package provides two ways to use the component, depending on your build setup and requirements.
|
|
24
|
+
|
|
25
|
+
### 1. Compiled Component (Recommended)
|
|
26
|
+
|
|
27
|
+
This is the standard way to use the library. It uses the pre-compiled JavaScript version, which is compatible with most modern bundlers.
|
|
28
|
+
|
|
29
|
+
**Important:** You must manually import the CSS file for styles to work.
|
|
30
|
+
|
|
31
|
+
```vue
|
|
32
|
+
<script setup>
|
|
33
|
+
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
|
|
34
|
+
|
|
35
|
+
import '@pdanpdan/virtual-scroll/style.css';
|
|
36
|
+
</script>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**Why use this?**
|
|
40
|
+
- Fastest build times (no need to compile the component logic).
|
|
41
|
+
- Maximum compatibility with different build tools.
|
|
42
|
+
- Scoped CSS works perfectly as it is extracted into `style.css` with unique data attributes.
|
|
43
|
+
|
|
44
|
+
### 2. Original Vue SFC
|
|
45
|
+
|
|
46
|
+
If you want to compile the component yourself using your own Vue compiler configuration, you can import the raw `.vue` file.
|
|
47
|
+
|
|
48
|
+
```vue
|
|
49
|
+
<script setup>
|
|
50
|
+
import VirtualScroll from '@pdanpdan/virtual-scroll/VirtualScroll.vue';
|
|
51
|
+
// No need to import CSS; it's handled by your Vue loader/plugin
|
|
52
|
+
</script>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**Why use this?**
|
|
56
|
+
- Allows for better tree-shaking and optimization by your own bundler.
|
|
57
|
+
- Enables deep integration with your project's CSS-in-JS or specialized styling solutions.
|
|
58
|
+
- Easier debugging of the component source in some IDEs.
|
|
59
|
+
|
|
60
|
+
## Basic Usage
|
|
61
|
+
|
|
62
|
+
```vue
|
|
63
|
+
<script setup>
|
|
64
|
+
import { VirtualScroll } from '@pdanpdan/virtual-scroll';
|
|
65
|
+
|
|
66
|
+
import '@pdanpdan/virtual-scroll/style.css';
|
|
67
|
+
|
|
68
|
+
const items = Array.from({ length: 10000 }, (_, i) => ({ id: i, label: `Item ${ i }` }));
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<div class="my-container">
|
|
73
|
+
<VirtualScroll :items="items" :item-size="50">
|
|
74
|
+
<template #item="{ item, index }">
|
|
75
|
+
<div class="my-item">
|
|
76
|
+
{{ index }}: {{ item.label }}
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
</VirtualScroll>
|
|
80
|
+
</div>
|
|
81
|
+
</template>
|
|
82
|
+
|
|
83
|
+
<style>
|
|
84
|
+
.my-container {
|
|
85
|
+
height: 500px;
|
|
86
|
+
overflow: auto;
|
|
87
|
+
}
|
|
88
|
+
.my-item {
|
|
89
|
+
height: 50px;
|
|
90
|
+
}
|
|
91
|
+
</style>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Props
|
|
95
|
+
|
|
96
|
+
### Core Configuration
|
|
97
|
+
| Prop | Type | Default | Description |
|
|
98
|
+
|------|------|---------|-------------|
|
|
99
|
+
| `items` | `T[]` | Required | Array of items to be virtualized. |
|
|
100
|
+
| `itemSize` | `number \| ((item: T, index: number) => number) \| null` | `50` | Fixed size of each item or a function that returns the size. Pass `0`, `null` or `undefined` for dynamic size detection. |
|
|
101
|
+
| `direction` | `'vertical' \| 'horizontal' \| 'both'` | `'vertical'` | Direction of the scroll. |
|
|
102
|
+
| `gap` | `number` | `0` | Gap between items in pixels (vertical). |
|
|
103
|
+
|
|
104
|
+
### Grid Configuration (direction="both")
|
|
105
|
+
| Prop | Type | Default | Description |
|
|
106
|
+
|------|------|---------|-------------|
|
|
107
|
+
| `columnCount` | `number` | `0` | Number of columns for bidirectional (grid) scroll. |
|
|
108
|
+
| `columnWidth` | `number \| number[] \| ((index: number) => number) \| null` | `150` | Fixed width of columns or an array/function for column widths. Pass `0`, `null` or `undefined` for dynamic width. |
|
|
109
|
+
| `columnGap` | `number` | `0` | Gap between columns in pixels. |
|
|
110
|
+
|
|
111
|
+
### Feature-Specific Props
|
|
112
|
+
| Prop | Type | Default | Description |
|
|
113
|
+
|------|------|---------|-------------|
|
|
114
|
+
| `stickyIndices` | `number[]` | `[]` | Indices of items that should stick to the top/start. Supports iOS-style pushing effect. |
|
|
115
|
+
| `stickyHeader` | `boolean` | `false` | Whether the header slot content is sticky. If true, header size is measured and added to `scrollPaddingStart`. |
|
|
116
|
+
| `stickyFooter` | `boolean` | `false` | Whether the footer slot content is sticky. If true, footer size is measured and added to `scrollPaddingEnd`. |
|
|
117
|
+
| `loading` | `boolean` | `false` | Whether items are currently being loaded. Prevents multiple `load` events and displays the `#loading` slot. |
|
|
118
|
+
| `loadDistance` | `number` | `200` | Distance from the end of the scrollable area to trigger `load` event. |
|
|
119
|
+
| `restoreScrollOnPrepend` | `boolean` | `false` | Whether to automatically restore scroll position when items are prepended to the list. |
|
|
120
|
+
|
|
121
|
+
### Advanced Configuration
|
|
122
|
+
| Prop | Type | Default | Description |
|
|
123
|
+
|------|------|---------|-------------|
|
|
124
|
+
| `container` | `HTMLElement \| Window \| null` | `host element` | The scrollable container element or window. |
|
|
125
|
+
| `scrollPaddingStart` | `number \| { x?: number; y?: number; }` | `0` | Padding at the start of the scroll container. |
|
|
126
|
+
| `scrollPaddingEnd` | `number \| { x?: number; y?: number; }` | `0` | Padding at the end of the scroll container. |
|
|
127
|
+
| `containerTag` | `string` | `'div'` | The HTML tag to use for the root container. |
|
|
128
|
+
| `wrapperTag` | `string` | `'div'` | The HTML tag to use for the items wrapper. |
|
|
129
|
+
| `itemTag` | `string` | `'div'` | The HTML tag to use for each item. |
|
|
130
|
+
| `bufferBefore` | `number` | `5` | Number of items to render before the visible viewport. |
|
|
131
|
+
| `bufferAfter` | `number` | `5` | Number of items to render after the visible viewport. |
|
|
132
|
+
| `ssrRange` | `{ start: number; end: number; colStart?: number; colEnd?: number; }` | `undefined` | Range of items to render for SSR. |
|
|
133
|
+
| `initialScrollIndex` | `number` | `undefined` | Initial scroll index to jump to on mount. |
|
|
134
|
+
| `initialScrollAlign` | `ScrollAlignment \| ScrollAlignmentOptions` | `'start'` | Alignment for the initial scroll index. |
|
|
135
|
+
|
|
136
|
+
### Development
|
|
137
|
+
| Prop | Type | Default | Description |
|
|
138
|
+
|------|------|---------|-------------|
|
|
139
|
+
| `defaultItemSize` | `number` | `50` | Default size for items before they are measured. |
|
|
140
|
+
| `defaultColumnWidth` | `number` | `150` | Default width for columns before they are measured. |
|
|
141
|
+
| `debug` | `boolean` | `false` | Enables debug visualization showing item indices and offsets. |
|
|
142
|
+
|
|
143
|
+
## Slots
|
|
144
|
+
|
|
145
|
+
- `item`: Scoped slot for individual items.
|
|
146
|
+
- `item`: The data item.
|
|
147
|
+
- `index`: The index of the item.
|
|
148
|
+
- `columnRange`: `{ start, end, padStart, padEnd }` information for grid mode.
|
|
149
|
+
- `getColumnWidth`: `(index: number) => number` helper for grid mode.
|
|
150
|
+
- `isSticky`: Whether the item is configured to be sticky.
|
|
151
|
+
- `isStickyActive`: Whether the item is currently stuck at the threshold.
|
|
152
|
+
- `header`: Content prepended to the scrollable area.
|
|
153
|
+
- `footer`: Content appended to the scrollable area.
|
|
154
|
+
- `loading`: Content shown at the end of the list when `loading` prop is true.
|
|
155
|
+
|
|
156
|
+
## Events
|
|
157
|
+
|
|
158
|
+
- `scroll`: Emitted when the container scrolls.
|
|
159
|
+
- `details`: `ScrollDetails<T>` object containing current state.
|
|
160
|
+
- `load`: Emitted when scrolling near the end of the content.
|
|
161
|
+
- `direction`: `'vertical'` or `'horizontal'`.
|
|
162
|
+
- `visibleRangeChange`: Emitted when the rendered items range or column range changes.
|
|
163
|
+
- `range`: `{ start: number; end: number; colStart: number; colEnd: number; }`
|
|
164
|
+
|
|
165
|
+
## Keyboard Navigation
|
|
166
|
+
|
|
167
|
+
When the container is focused, it supports the following keys:
|
|
168
|
+
- `Home` / `End`: Scroll to top/bottom or start/end of the list.
|
|
169
|
+
- `ArrowUp` / `ArrowDown`: Scroll up/down by 40px.
|
|
170
|
+
- `ArrowLeft` / `ArrowRight`: Scroll left/right by 40px.
|
|
171
|
+
- `PageUp` / `PageDown`: Scroll up/down (or left/right) by one viewport size.
|
|
172
|
+
|
|
173
|
+
## Methods (Exposed)
|
|
174
|
+
|
|
175
|
+
- `scrollToIndex(rowIndex: number | null, colIndex: number | null, options?: ScrollAlignment | ScrollAlignmentOptions | ScrollToIndexOptions)`
|
|
176
|
+
- `rowIndex`: Row index to scroll to.
|
|
177
|
+
- `colIndex`: Column index to scroll to (for horizontal or grid).
|
|
178
|
+
- `options`:
|
|
179
|
+
- `align`: `'start' | 'center' | 'end' | 'auto'` or `{ x, y }` alignments.
|
|
180
|
+
- `behavior`: `'auto' | 'smooth'`.
|
|
181
|
+
- `scrollToOffset(x: number | null, y: number | null, options?: { behavior?: 'auto' | 'smooth' })`
|
|
182
|
+
- `x`: Pixel offset on X axis.
|
|
183
|
+
- `y`: Pixel offset on Y axis.
|
|
184
|
+
- `refresh()`: Resets all dynamic measurements and re-initializes sizes from current items and props.
|
|
185
|
+
|
|
186
|
+
## Types
|
|
187
|
+
|
|
188
|
+
### ScrollDetails<T>
|
|
189
|
+
```typescript
|
|
190
|
+
/* eslint-disable unused-imports/no-unused-vars */
|
|
191
|
+
interface ScrollDetails<T = unknown> {
|
|
192
|
+
items: Array<{
|
|
193
|
+
item: T;
|
|
194
|
+
index: number;
|
|
195
|
+
offset: { x: number; y: number; };
|
|
196
|
+
size: { width: number; height: number; };
|
|
197
|
+
originalX: number;
|
|
198
|
+
originalY: number;
|
|
199
|
+
isSticky?: boolean;
|
|
200
|
+
isStickyActive?: boolean;
|
|
201
|
+
stickyOffset: { x: number; y: number; };
|
|
202
|
+
}>;
|
|
203
|
+
currentIndex: number;
|
|
204
|
+
currentColIndex: number;
|
|
205
|
+
scrollOffset: { x: number; y: number; };
|
|
206
|
+
viewportSize: { width: number; height: number; };
|
|
207
|
+
totalSize: { width: number; height: number; };
|
|
208
|
+
isScrolling: boolean;
|
|
209
|
+
isProgrammaticScroll: boolean;
|
|
210
|
+
range: { start: number; end: number; };
|
|
211
|
+
columnRange: { start: number; end: number; padStart: number; padEnd: number; };
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### RenderedItem<T>
|
|
216
|
+
```typescript
|
|
217
|
+
/* eslint-disable unused-imports/no-unused-vars */
|
|
218
|
+
interface RenderedItem<T = unknown> {
|
|
219
|
+
item: T;
|
|
220
|
+
index: number;
|
|
221
|
+
offset: { x: number; y: number; };
|
|
222
|
+
size: { width: number; height: number; };
|
|
223
|
+
originalX: number;
|
|
224
|
+
originalY: number;
|
|
225
|
+
isSticky?: boolean;
|
|
226
|
+
isStickyActive?: boolean;
|
|
227
|
+
stickyOffset: { x: number; y: number; };
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## CSS Classes
|
|
232
|
+
|
|
233
|
+
- `.virtual-scroll-container`: Root container.
|
|
234
|
+
- `.virtual-scroll-wrapper`: Items wrapper.
|
|
235
|
+
- `.virtual-scroll-item`: Individual item.
|
|
236
|
+
- `.virtual-scroll-header` / `.virtual-scroll-footer`: Header/Footer slots.
|
|
237
|
+
- `.virtual-scroll-loading`: Loading slot container.
|
|
238
|
+
- `.virtual-scroll--sticky`: Applied to sticky elements.
|
|
239
|
+
- `.virtual-scroll--hydrated`: Applied when client-side mount is complete.
|
|
240
|
+
- `.virtual-scroll--window`: Applied when using window/body scroll.
|
|
241
|
+
- `.virtual-scroll--table`: Applied when `containerTag="table"` is used.
|
|
242
|
+
|
|
243
|
+
## SSR Support
|
|
244
|
+
|
|
245
|
+
The component is designed to be SSR-friendly. You can pre-render a specific range of items on the server using the `ssrRange` prop.
|
|
246
|
+
|
|
247
|
+
```vue
|
|
248
|
+
<VirtualScroll
|
|
249
|
+
:items="items"
|
|
250
|
+
:ssr-range="{ start: 100, end: 120, colStart: 50, colEnd: 70 }"
|
|
251
|
+
>
|
|
252
|
+
<!-- ... -->
|
|
253
|
+
</VirtualScroll>
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
When `ssrRange` is provided:
|
|
257
|
+
1. **Server-Side**: Only the specified range of items is rendered. Items are rendered in-flow (relative positioning) with their offsets adjusted so the specified range appears at the top-left of the container.
|
|
258
|
+
2. **Client-Side Hydration**:
|
|
259
|
+
- The component initially renders the SSR content to match the server-generated HTML.
|
|
260
|
+
- On mount, it expands the container size and automatically scrolls to exactly match the pre-rendered range using `align: 'start'`.
|
|
261
|
+
- It then seamlessly transitions to virtual mode (absolute positioning) while maintaining the scroll position.
|
|
262
|
+
|
|
263
|
+
## Composable API
|
|
264
|
+
|
|
265
|
+
For advanced use cases, you can use the underlying logic via the `useVirtualScroll` composable.
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import { useVirtualScroll } from '@pdanpdan/virtual-scroll';
|
|
269
|
+
import { computed, ref } from 'vue';
|
|
270
|
+
|
|
271
|
+
const items = ref([ { id: 1 }, { id: 2 } ]);
|
|
272
|
+
const props = computed(() => ({
|
|
273
|
+
items: items.value,
|
|
274
|
+
itemSize: 50,
|
|
275
|
+
direction: 'vertical' as const,
|
|
276
|
+
}));
|
|
277
|
+
|
|
278
|
+
const { renderedItems, scrollDetails } = useVirtualScroll(props);
|
|
279
|
+
console.log(renderedItems.value, scrollDetails.value);
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Utilities
|
|
283
|
+
|
|
284
|
+
The library exports several utility functions for scroll-related checks:
|
|
285
|
+
|
|
286
|
+
- `isElement(container: HTMLElement | Window | null | undefined): container is HTMLElement`: Checks if the container is an `HTMLElement` (and not `Window`).
|
|
287
|
+
- `isScrollableElement(target: EventTarget | null): target is HTMLElement`: Checks if the target is an `HTMLElement` with scroll properties.
|
|
288
|
+
- `isScrollToIndexOptions(options: unknown): options is ScrollToIndexOptions`: Checks if the options object is a full `ScrollToIndexOptions` object.
|
|
289
|
+
|
|
290
|
+
## License
|
|
291
|
+
|
|
292
|
+
MIT
|
package/dist/index.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.virtual-scroll-container[data-v-654a1d54]{position:relative;block-size:100%;inline-size:100%;outline-offset:1px}.virtual-scroll-container[data-v-654a1d54]:not(.virtual-scroll--window){overflow:auto;overscroll-behavior:contain}.virtual-scroll-container.virtual-scroll--table[data-v-654a1d54]{display:block}.virtual-scroll--horizontal[data-v-654a1d54]{white-space:nowrap}.virtual-scroll-wrapper[data-v-654a1d54]{contain:layout;position:relative}:where(.virtual-scroll--hydrated>.virtual-scroll-wrapper>.virtual-scroll-item[data-v-654a1d54]){position:absolute;inset-block-start:0;inset-inline-start:0}.virtual-scroll-item[data-v-654a1d54]{box-sizing:border-box;will-change:transform}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-654a1d54]{outline:1px dashed rgba(255,0,0,.5);background-color:#ff00000d}.virtual-scroll-item:where(.virtual-scroll--debug)[data-v-654a1d54]:where(:hover){background-color:#ff00001a;z-index:100}.virtual-scroll-debug-info[data-v-654a1d54]{position:absolute;inset-block-start:2px;inset-inline-end:2px;background:#000000b3;color:#fff;font-size:10px;padding:2px 4px;border-radius:4px;pointer-events:none;z-index:100;font-family:monospace}.virtual-scroll-spacer[data-v-654a1d54]{pointer-events:none}.virtual-scroll-header[data-v-654a1d54],.virtual-scroll-footer[data-v-654a1d54]{position:relative;z-index:20}.virtual-scroll--sticky[data-v-654a1d54]{position:sticky}.virtual-scroll--sticky[data-v-654a1d54]:where(.virtual-scroll-header){inset-block-start:0;inset-inline-start:0;min-inline-size:100%;box-sizing:border-box}.virtual-scroll--sticky[data-v-654a1d54]:where(.virtual-scroll-footer){inset-block-end:0;inset-inline-start:0;min-inline-size:100%;box-sizing:border-box}.virtual-scroll--sticky[data-v-654a1d54]:where(.virtual-scroll-item){z-index:10}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-654a1d54]{display:inline-flex;min-inline-size:100%}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-654a1d54]>tr{display:inline-flex;min-inline-size:100%}:is(tbody.virtual-scroll-wrapper,thead.virtual-scroll-header,tfoot.virtual-scroll-footer)[data-v-654a1d54]>tr>:is(td,th){display:inline-block;align-items:center}
|