@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 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&lt;T&gt;
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&lt;T&gt;
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}