vue-virtual-flow 0.0.1
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/LICENSE +21 -0
- package/README.md +808 -0
- package/README.vi.md +805 -0
- package/dist/components/ChatVirtualScroll.d.cts +46 -0
- package/dist/components/ChatVirtualScroll.d.ts +46 -0
- package/dist/components/DynamicVirtualScroll.d.cts +50 -0
- package/dist/components/DynamicVirtualScroll.d.ts +50 -0
- package/dist/components/ShortMediaFeed.d.cts +37 -0
- package/dist/components/ShortMediaFeed.d.ts +37 -0
- package/dist/components/VirtualCarousel.d.cts +41 -0
- package/dist/components/VirtualCarousel.d.ts +41 -0
- package/dist/components/VirtualScroll.d.cts +50 -0
- package/dist/components/VirtualScroll.d.ts +50 -0
- package/dist/components/WindowDynamicVirtualScroll.d.cts +49 -0
- package/dist/components/WindowDynamicVirtualScroll.d.ts +49 -0
- package/dist/composables/useItemKey.d.cts +10 -0
- package/dist/composables/useItemKey.d.ts +10 -0
- package/dist/composables/usePullToRefresh.d.cts +20 -0
- package/dist/composables/usePullToRefresh.d.ts +20 -0
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/types.d.cts +119 -0
- package/dist/types.d.ts +119 -0
- package/dist/utils/layout.d.cts +2 -0
- package/dist/utils/layout.d.ts +2 -0
- package/dist/vue-virtual-flow.cjs +1 -0
- package/dist/vue-virtual-flow.css +1 -0
- package/dist/vue-virtual-flow.js +1628 -0
- package/package.json +82 -0
package/README.md
ADDED
|
@@ -0,0 +1,808 @@
|
|
|
1
|
+
# vue-virtual-flow
|
|
2
|
+
|
|
3
|
+
[English](./README.md) | [Tiếng Việt](./README.vi.md)
|
|
4
|
+
|
|
5
|
+
A small, typed, and accessible collection of virtual scrolling components for
|
|
6
|
+
Vue 3. It covers fixed and variable-height lists, window scrolling, chat,
|
|
7
|
+
short-media feeds, and horizontal carousels.
|
|
8
|
+
|
|
9
|
+
Only the items needed for the current viewport are mounted, keeping large data
|
|
10
|
+
sets responsive without prescribing how an item should look.
|
|
11
|
+
|
|
12
|
+
## Table of contents
|
|
13
|
+
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Quick start](#quick-start)
|
|
16
|
+
- [Choose a component](#choose-a-component)
|
|
17
|
+
- [Component guides](#component-guides)
|
|
18
|
+
- [VirtualList and DynamicVirtualScroll](#virtuallist-and-dynamicvirtualscroll)
|
|
19
|
+
- [VirtualScroll](#virtualscroll)
|
|
20
|
+
- [WindowDynamicVirtualScroll](#windowdynamicvirtualscroll)
|
|
21
|
+
- [ChatVirtualScroll](#chatvirtualscroll)
|
|
22
|
+
- [ShortMediaFeed](#shortmediafeed)
|
|
23
|
+
- [VirtualCarousel](#virtualcarousel)
|
|
24
|
+
- [Shared recipes](#shared-recipes)
|
|
25
|
+
- [Viewport height](#viewport-height)
|
|
26
|
+
- [Stable item keys](#stable-item-keys)
|
|
27
|
+
- [Load more](#load-more)
|
|
28
|
+
- [Pull to refresh](#pull-to-refresh)
|
|
29
|
+
- [TypeScript exports](#typescript-exports)
|
|
30
|
+
- [Development and release](#development-and-release)
|
|
31
|
+
- [Contributing](#contributing)
|
|
32
|
+
- [License](#license)
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm install vue-virtual-flow
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Import the library stylesheet once in your application entry point:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import 'vue-virtual-flow/style.css'
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The package requires Vue `^3.4.0` and Node.js `>=18`.
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
Import components locally:
|
|
51
|
+
|
|
52
|
+
```vue
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { VirtualList } from 'vue-virtual-flow'
|
|
55
|
+
import 'vue-virtual-flow/style.css'
|
|
56
|
+
|
|
57
|
+
const users = Array.from({ length: 10_000 }, (_, id) => ({
|
|
58
|
+
id,
|
|
59
|
+
name: `User ${id + 1}`,
|
|
60
|
+
}))
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<template>
|
|
64
|
+
<VirtualList :items="users" item-key="id">
|
|
65
|
+
<template #default="{ item, index }">
|
|
66
|
+
<div>{{ index + 1 }}. {{ item.name }}</div>
|
|
67
|
+
</template>
|
|
68
|
+
|
|
69
|
+
<template #empty>No users found.</template>
|
|
70
|
+
</VirtualList>
|
|
71
|
+
</template>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Or install every component globally:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { createApp } from 'vue'
|
|
78
|
+
import VueVirtualScroll from 'vue-virtual-flow'
|
|
79
|
+
import 'vue-virtual-flow/style.css'
|
|
80
|
+
import App from './App.vue'
|
|
81
|
+
|
|
82
|
+
createApp(App).use(VueVirtualScroll).mount('#app')
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The plugin registers `VirtualList`, `DynamicVirtualScroll`, `VirtualScroll`,
|
|
86
|
+
`WindowDynamicVirtualScroll`, `ChatVirtualScroll`, `ShortMediaFeed`, and
|
|
87
|
+
`VirtualCarousel`.
|
|
88
|
+
|
|
89
|
+
## Choose a component
|
|
90
|
+
|
|
91
|
+
| Component | Use it when | Scroll viewport | Item size |
|
|
92
|
+
| --- | --- | --- | --- |
|
|
93
|
+
| `VirtualList` | You want the simplest general-purpose list. | Component container | Variable, measured automatically |
|
|
94
|
+
| `DynamicVirtualScroll` | Same behavior as `VirtualList`, with an explicit name. | Component container | Variable, measured automatically |
|
|
95
|
+
| `VirtualScroll` | Every row has the same known height. | Component container | Fixed |
|
|
96
|
+
| `WindowDynamicVirtualScroll` | The page itself should scroll instead of a nested container. | Browser window | Variable, measured automatically |
|
|
97
|
+
| `ChatVirtualScroll` | Messages are appended at the bottom and older history is prepended. | Component container | Variable, measured automatically |
|
|
98
|
+
| `ShortMediaFeed` | One full-height item should snap into view at a time. | Component container | One viewport per item |
|
|
99
|
+
| `VirtualCarousel` | Several horizontal slides should snap and virtualize. | Component container | Calculated from container width |
|
|
100
|
+
|
|
101
|
+
## Component guides
|
|
102
|
+
|
|
103
|
+
### VirtualList and DynamicVirtualScroll
|
|
104
|
+
|
|
105
|
+
`VirtualList` is an alias of `DynamicVirtualScroll`. Both names render the same
|
|
106
|
+
component and share the same API. Use it for cards, search results, activity
|
|
107
|
+
feeds, or any list whose rows can have different heights.
|
|
108
|
+
|
|
109
|
+
Rows initially use `estimatedItemSize`. `ResizeObserver` then replaces each
|
|
110
|
+
estimate with the measured height. Measurement changes above the viewport are
|
|
111
|
+
compensated to avoid visible jumps.
|
|
112
|
+
|
|
113
|
+
#### Example
|
|
114
|
+
|
|
115
|
+
```vue
|
|
116
|
+
<script setup lang="ts">
|
|
117
|
+
import { ref } from 'vue'
|
|
118
|
+
import {
|
|
119
|
+
VirtualList,
|
|
120
|
+
type VirtualScrollEvent,
|
|
121
|
+
type VirtualListExpose,
|
|
122
|
+
} from 'vue-virtual-flow'
|
|
123
|
+
|
|
124
|
+
const list = ref<VirtualListExpose>()
|
|
125
|
+
const posts = ref([
|
|
126
|
+
{ id: 'p1', title: 'Short post', body: 'One line.' },
|
|
127
|
+
{
|
|
128
|
+
id: 'p2',
|
|
129
|
+
title: 'Long post',
|
|
130
|
+
body: 'Content that wraps and gives this row a different height.',
|
|
131
|
+
},
|
|
132
|
+
])
|
|
133
|
+
|
|
134
|
+
function onScroll(event: VirtualScrollEvent) {
|
|
135
|
+
console.log(event.scrollTop, event.startIndex, event.endIndex)
|
|
136
|
+
}
|
|
137
|
+
</script>
|
|
138
|
+
|
|
139
|
+
<template>
|
|
140
|
+
<VirtualList
|
|
141
|
+
ref="list"
|
|
142
|
+
:items="posts"
|
|
143
|
+
:estimated-item-size="96"
|
|
144
|
+
:height="500"
|
|
145
|
+
:overscan="4"
|
|
146
|
+
item-key="id"
|
|
147
|
+
aria-label="Posts"
|
|
148
|
+
@scroll="onScroll"
|
|
149
|
+
>
|
|
150
|
+
<template #default="{ item, index }">
|
|
151
|
+
<article>
|
|
152
|
+
<small>#{{ index + 1 }}</small>
|
|
153
|
+
<h2>{{ item.title }}</h2>
|
|
154
|
+
<p>{{ item.body }}</p>
|
|
155
|
+
</article>
|
|
156
|
+
</template>
|
|
157
|
+
|
|
158
|
+
<template #empty>No posts yet.</template>
|
|
159
|
+
</VirtualList>
|
|
160
|
+
|
|
161
|
+
<button @click="list?.scrollToIndex(1, { align: 'center' })">
|
|
162
|
+
Go to the second post
|
|
163
|
+
</button>
|
|
164
|
+
</template>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Choose an estimate close to the average rendered row height. Rows do not need
|
|
168
|
+
to match it; a good estimate mainly improves the initial scrollbar and jumps
|
|
169
|
+
to items that have not been measured.
|
|
170
|
+
|
|
171
|
+
#### Props
|
|
172
|
+
|
|
173
|
+
| Prop | Type | Default | Description |
|
|
174
|
+
| --- | --- | --- | --- |
|
|
175
|
+
| `items` | `readonly T[]` | required | Data passed to the default slot. |
|
|
176
|
+
| `estimatedItemSize` | `number` | `48` | Initial average row height in pixels. |
|
|
177
|
+
| `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
|
|
178
|
+
| `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
|
|
179
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity used for keys and measurements. |
|
|
180
|
+
| `ariaLabel` | `string` | `"Dynamic virtual list"` | Accessible label for the list. |
|
|
181
|
+
| `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
|
|
182
|
+
| `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
|
|
183
|
+
| `loadingItemSize` | `number` | `estimatedItemSize` | Reserved loading-row height in pixels. |
|
|
184
|
+
| `loadMoreThreshold` | `number` | `200` | Distance from the end, in pixels, that triggers `load-more`. |
|
|
185
|
+
| `pullToRefresh` | `boolean` | `false` | Enables the touch pull-down gesture at the top. |
|
|
186
|
+
| `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
|
|
187
|
+
| `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
|
|
188
|
+
|
|
189
|
+
#### Slots
|
|
190
|
+
|
|
191
|
+
| Slot | Scope | Description |
|
|
192
|
+
| --- | --- | --- |
|
|
193
|
+
| `default` | `{ item, index }` | Renders each mounted row. |
|
|
194
|
+
| `empty` | none | Rendered when `items` is empty and `loading` is false. |
|
|
195
|
+
| `loading` | none | Loading row; defaults to “Loading more…”. |
|
|
196
|
+
| `refresh` | `{ pullDistance, progress, refreshing }` | Pull-to-refresh indicator. `progress` is clamped from `0` to `1`. |
|
|
197
|
+
|
|
198
|
+
#### Events
|
|
199
|
+
|
|
200
|
+
| Event | Payload | When it fires |
|
|
201
|
+
| --- | --- | --- |
|
|
202
|
+
| `scroll` | `{ scrollTop, startIndex, endIndex }` | On container scroll. `endIndex` is exclusive. |
|
|
203
|
+
| `load-more` | none | Near the end when `hasMore` is true and `loading` is false. |
|
|
204
|
+
| `refresh` | none | After pulling past the refresh threshold and releasing. |
|
|
205
|
+
|
|
206
|
+
#### Exposed API
|
|
207
|
+
|
|
208
|
+
| Method | Description |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `scrollTo(position, options?)` | Scrolls to a pixel offset inside the list. |
|
|
211
|
+
| `scrollToIndex(index, { align?, behavior? })` | Scrolls to a zero-based index. `align` is `start`, `center`, or `end`. |
|
|
212
|
+
| `scrollToTop(behavior?)` | Scrolls to the start of the list. |
|
|
213
|
+
|
|
214
|
+
Long-distance smooth jumps automatically become immediate jumps because
|
|
215
|
+
unmeasured rows can change the target offset while scrolling.
|
|
216
|
+
|
|
217
|
+
### VirtualScroll
|
|
218
|
+
|
|
219
|
+
Use `VirtualScroll` when every row has exactly the same height. It does not
|
|
220
|
+
need to measure rows, making it the most predictable option for tables, menus,
|
|
221
|
+
logs, and dense result lists.
|
|
222
|
+
|
|
223
|
+
#### Example
|
|
224
|
+
|
|
225
|
+
```vue
|
|
226
|
+
<script setup lang="ts">
|
|
227
|
+
import { ref } from 'vue'
|
|
228
|
+
import { VirtualScroll, type VirtualScrollExpose } from 'vue-virtual-flow'
|
|
229
|
+
|
|
230
|
+
const list = ref<VirtualScrollExpose>()
|
|
231
|
+
const rows = Array.from({ length: 50_000 }, (_, id) => ({
|
|
232
|
+
id,
|
|
233
|
+
label: `Row ${id + 1}`,
|
|
234
|
+
}))
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<template>
|
|
238
|
+
<VirtualScroll
|
|
239
|
+
ref="list"
|
|
240
|
+
:items="rows"
|
|
241
|
+
:item-size="44"
|
|
242
|
+
:height="480"
|
|
243
|
+
:overscan="5"
|
|
244
|
+
item-key="id"
|
|
245
|
+
aria-label="Results"
|
|
246
|
+
>
|
|
247
|
+
<template #default="{ item }">
|
|
248
|
+
<div class="result-row">{{ item.label }}</div>
|
|
249
|
+
</template>
|
|
250
|
+
</VirtualScroll>
|
|
251
|
+
|
|
252
|
+
<button @click="list?.scrollToIndex(999, { align: 'center' })">
|
|
253
|
+
Go to row 1,000
|
|
254
|
+
</button>
|
|
255
|
+
</template>
|
|
256
|
+
|
|
257
|
+
<style scoped>
|
|
258
|
+
.result-row {
|
|
259
|
+
height: 44px;
|
|
260
|
+
box-sizing: border-box;
|
|
261
|
+
}
|
|
262
|
+
</style>
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The rendered row must stay at `itemSize`. If content can wrap or resize, use
|
|
266
|
+
`VirtualList` instead.
|
|
267
|
+
|
|
268
|
+
#### Props
|
|
269
|
+
|
|
270
|
+
| Prop | Type | Default | Description |
|
|
271
|
+
| --- | --- | --- | --- |
|
|
272
|
+
| `items` | `readonly T[]` | required | Data passed to the default slot. |
|
|
273
|
+
| `itemSize` | `number` | required | Exact height of every row in pixels. |
|
|
274
|
+
| `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
|
|
275
|
+
| `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
|
|
276
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each row. |
|
|
277
|
+
| `ariaLabel` | `string` | `"Virtual list"` | Accessible label for the list. |
|
|
278
|
+
| `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
|
|
279
|
+
| `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
|
|
280
|
+
| `loadingItemSize` | `number` | `itemSize` | Reserved loading-row height in pixels. |
|
|
281
|
+
| `loadMoreThreshold` | `number` | `200` | Distance from the end, in pixels, that triggers `load-more`. |
|
|
282
|
+
| `pullToRefresh` | `boolean` | `false` | Enables the touch pull-down gesture at the top. |
|
|
283
|
+
| `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
|
|
284
|
+
| `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
|
|
285
|
+
|
|
286
|
+
#### Slots, events, and exposed API
|
|
287
|
+
|
|
288
|
+
- Slots: `default({ item, index })`, `empty`, `loading`, and
|
|
289
|
+
`refresh({ pullDistance, progress, refreshing })`.
|
|
290
|
+
- Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more`, and
|
|
291
|
+
`refresh`.
|
|
292
|
+
- Methods: `scrollTo`, `scrollToIndex`, and `scrollToTop`.
|
|
293
|
+
|
|
294
|
+
### WindowDynamicVirtualScroll
|
|
295
|
+
|
|
296
|
+
Use `WindowDynamicVirtualScroll` for a long page or feed where the browser
|
|
297
|
+
window—not an inner element—is the scrolling viewport. It measures
|
|
298
|
+
variable-height rows and listens to `window.scrollY` and `window.innerHeight`.
|
|
299
|
+
There is no `height` prop.
|
|
300
|
+
|
|
301
|
+
#### Example
|
|
302
|
+
|
|
303
|
+
```vue
|
|
304
|
+
<script setup lang="ts">
|
|
305
|
+
import { ref } from 'vue'
|
|
306
|
+
import {
|
|
307
|
+
WindowDynamicVirtualScroll,
|
|
308
|
+
type VirtualScrollExpose,
|
|
309
|
+
} from 'vue-virtual-flow'
|
|
310
|
+
|
|
311
|
+
const feed = ref(loadInitialFeed())
|
|
312
|
+
const list = ref<VirtualScrollExpose>()
|
|
313
|
+
</script>
|
|
314
|
+
|
|
315
|
+
<template>
|
|
316
|
+
<header>Content before the virtual list</header>
|
|
317
|
+
|
|
318
|
+
<WindowDynamicVirtualScroll
|
|
319
|
+
ref="list"
|
|
320
|
+
:items="feed"
|
|
321
|
+
:estimated-item-size="320"
|
|
322
|
+
:overscan="3"
|
|
323
|
+
item-key="id"
|
|
324
|
+
aria-label="Article feed"
|
|
325
|
+
>
|
|
326
|
+
<template #default="{ item, index }">
|
|
327
|
+
<article>
|
|
328
|
+
<small>{{ index + 1 }}</small>
|
|
329
|
+
<h2>{{ item.title }}</h2>
|
|
330
|
+
<p>{{ item.summary }}</p>
|
|
331
|
+
</article>
|
|
332
|
+
</template>
|
|
333
|
+
</WindowDynamicVirtualScroll>
|
|
334
|
+
|
|
335
|
+
<footer>Content after the virtual list</footer>
|
|
336
|
+
</template>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
#### Props
|
|
340
|
+
|
|
341
|
+
| Prop | Type | Default | Description |
|
|
342
|
+
| --- | --- | --- | --- |
|
|
343
|
+
| `items` | `readonly T[]` | required | Data passed to the default slot. |
|
|
344
|
+
| `estimatedItemSize` | `number` | `48` | Initial average row height in pixels. |
|
|
345
|
+
| `overscan` | `number` | `3` | Extra rows mounted before and after the visible range. |
|
|
346
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity used for keys and measurements. |
|
|
347
|
+
| `ariaLabel` | `string` | `"Window dynamic virtual list"` | Accessible label for the list. |
|
|
348
|
+
| `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
|
|
349
|
+
| `loading` | `boolean` | `false` | Prevents duplicate requests and shows the loading row. |
|
|
350
|
+
| `loadingItemSize` | `number` | `estimatedItemSize` | Reserved loading-row height in pixels. |
|
|
351
|
+
| `loadMoreThreshold` | `number` | `200` | Distance between the window bottom and list end that triggers `load-more`. |
|
|
352
|
+
| `pullToRefresh` | `boolean` | `false` | Enables refresh while the page is at `window.scrollY = 0`. |
|
|
353
|
+
| `refreshing` | `boolean` | `false` | Keeps the refresh indicator open during async work. |
|
|
354
|
+
| `pullRefreshThreshold` | `number` | `64` | Pull distance required to emit `refresh`, in pixels. |
|
|
355
|
+
|
|
356
|
+
#### Slots, events, and exposed API
|
|
357
|
+
|
|
358
|
+
- Slots: `default({ item, index })`, `empty`, `loading`, and
|
|
359
|
+
`refresh({ pullDistance, progress, refreshing })`.
|
|
360
|
+
- Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more`, and
|
|
361
|
+
`refresh`. `scrollTop` is relative to the component start.
|
|
362
|
+
- Methods: `scrollTo`, `scrollToIndex`, and `scrollToTop`. They scroll the
|
|
363
|
+
browser window; `scrollTo` accepts an offset relative to the list.
|
|
364
|
+
|
|
365
|
+
### ChatVirtualScroll
|
|
366
|
+
|
|
367
|
+
`ChatVirtualScroll` is designed for variable-height conversations. It opens at
|
|
368
|
+
the newest message by default, follows appended messages only while the user
|
|
369
|
+
is near the bottom, and preserves the visible message when older items are
|
|
370
|
+
prepended.
|
|
371
|
+
|
|
372
|
+
#### Example
|
|
373
|
+
|
|
374
|
+
```vue
|
|
375
|
+
<script setup lang="ts">
|
|
376
|
+
import { ref } from 'vue'
|
|
377
|
+
import {
|
|
378
|
+
ChatVirtualScroll,
|
|
379
|
+
type ChatVirtualScrollExpose,
|
|
380
|
+
} from 'vue-virtual-flow'
|
|
381
|
+
|
|
382
|
+
const chat = ref<ChatVirtualScrollExpose>()
|
|
383
|
+
const loadingOlder = ref(false)
|
|
384
|
+
const messages = ref(loadLatestMessages())
|
|
385
|
+
const hasOlder = ref(true)
|
|
386
|
+
|
|
387
|
+
async function loadOlder() {
|
|
388
|
+
if (loadingOlder.value || !hasOlder.value) return
|
|
389
|
+
loadingOlder.value = true
|
|
390
|
+
const page = await fetchOlderPage(messages.value[0]?.id)
|
|
391
|
+
messages.value.unshift(...page.items)
|
|
392
|
+
hasOlder.value = page.hasMore
|
|
393
|
+
loadingOlder.value = false
|
|
394
|
+
}
|
|
395
|
+
</script>
|
|
396
|
+
|
|
397
|
+
<template>
|
|
398
|
+
<ChatVirtualScroll
|
|
399
|
+
ref="chat"
|
|
400
|
+
:items="messages"
|
|
401
|
+
:estimated-item-size="72"
|
|
402
|
+
:height="600"
|
|
403
|
+
:has-older="hasOlder"
|
|
404
|
+
:loading-older="loadingOlder"
|
|
405
|
+
:load-older-threshold="120"
|
|
406
|
+
item-key="id"
|
|
407
|
+
aria-label="Support conversation"
|
|
408
|
+
@load-older="loadOlder"
|
|
409
|
+
>
|
|
410
|
+
<template #default="{ item }">
|
|
411
|
+
<MessageBubble :message="item" />
|
|
412
|
+
</template>
|
|
413
|
+
|
|
414
|
+
<template #loadingOlder>Loading older messages…</template>
|
|
415
|
+
<template #empty>No messages.</template>
|
|
416
|
+
</ChatVirtualScroll>
|
|
417
|
+
|
|
418
|
+
<button v-if="!chat?.isAtBottom" @click="chat?.scrollToBottom('smooth')">
|
|
419
|
+
Newest messages
|
|
420
|
+
</button>
|
|
421
|
+
</template>
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
Prepend older data to the same keyed collection. The component calculates the
|
|
425
|
+
height added above the viewport and restores the reader's position.
|
|
426
|
+
|
|
427
|
+
#### Props
|
|
428
|
+
|
|
429
|
+
| Prop | Type | Default | Description |
|
|
430
|
+
| --- | --- | --- | --- |
|
|
431
|
+
| `items` | `readonly T[]` | required | Messages ordered from oldest to newest. |
|
|
432
|
+
| `estimatedItemSize` | `number` | `48` | Initial average message height in pixels. |
|
|
433
|
+
| `height` | `number \| string` | `400` | Viewport height. A number is pixels; `"fill"` maps to `100%`. |
|
|
434
|
+
| `overscan` | `number` | `5` | Extra messages mounted before and after the visible range. |
|
|
435
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity; especially important when prepending. |
|
|
436
|
+
| `ariaLabel` | `string` | `"Chat messages"` | Accessible label for the live log. |
|
|
437
|
+
| `stickToBottom` | `boolean` | `true` | Follows appended messages while currently near the bottom. |
|
|
438
|
+
| `bottomThreshold` | `number` | `80` | Distance still considered “at bottom”, in pixels. |
|
|
439
|
+
| `initialScroll` | `"top" \| "bottom"` | `"bottom"` | Initial position after mount. |
|
|
440
|
+
| `hasOlder` | `boolean` | `false` | Indicates that older history is available. |
|
|
441
|
+
| `loadingOlder` | `boolean` | `false` | Prevents duplicate requests and shows the top indicator. |
|
|
442
|
+
| `loadOlderThreshold` | `number` | `120` | Distance from the top that triggers `load-older`, in pixels. |
|
|
443
|
+
|
|
444
|
+
#### Slots
|
|
445
|
+
|
|
446
|
+
| Slot | Scope | Description |
|
|
447
|
+
| --- | --- | --- |
|
|
448
|
+
| `default` | `{ item, index }` | Renders each mounted message. |
|
|
449
|
+
| `empty` | none | Rendered when there are no messages. |
|
|
450
|
+
| `loadingOlder` | none | Sticky top status; defaults to “Loading older messages…”. |
|
|
451
|
+
|
|
452
|
+
#### Events
|
|
453
|
+
|
|
454
|
+
| Event | Payload | When it fires |
|
|
455
|
+
| --- | --- | --- |
|
|
456
|
+
| `scroll` | `{ scrollTop, startIndex, endIndex }` | On container scroll. |
|
|
457
|
+
| `load-older` | none | Near the top when `hasOlder` is true and `loadingOlder` is false. |
|
|
458
|
+
| `bottom-change` | `boolean` | When the viewport enters or leaves the bottom threshold. |
|
|
459
|
+
|
|
460
|
+
#### Exposed API
|
|
461
|
+
|
|
462
|
+
| Member | Description |
|
|
463
|
+
| --- | --- |
|
|
464
|
+
| `isAtBottom` | Readonly boolean indicating whether the user is near the bottom. |
|
|
465
|
+
| `scrollTo(position, options?)` | Scrolls to a pixel offset. |
|
|
466
|
+
| `scrollToIndex(index, { align?, behavior? })` | Scrolls to a zero-based message index. |
|
|
467
|
+
| `scrollToTop(behavior?)` | Scrolls to the oldest loaded message. |
|
|
468
|
+
| `scrollToBottom(behavior?)` | Scrolls to the newest loaded message. |
|
|
469
|
+
|
|
470
|
+
### ShortMediaFeed
|
|
471
|
+
|
|
472
|
+
`ShortMediaFeed` is a headless, vertically snapping viewport for reels,
|
|
473
|
+
stories, or short-form media. Each item occupies one complete viewport. The
|
|
474
|
+
component manages mounting and navigation; the slot owns video, images,
|
|
475
|
+
playback, audio, and controls.
|
|
476
|
+
|
|
477
|
+
#### Example
|
|
478
|
+
|
|
479
|
+
```vue
|
|
480
|
+
<script setup lang="ts">
|
|
481
|
+
import { ref } from 'vue'
|
|
482
|
+
import { ShortMediaFeed, type ShortMediaFeedExpose } from 'vue-virtual-flow'
|
|
483
|
+
|
|
484
|
+
const feed = ref<ShortMediaFeedExpose>()
|
|
485
|
+
const activeIndex = ref(0)
|
|
486
|
+
const media = ref([
|
|
487
|
+
{ id: 1, src: '/media/one.mp4' },
|
|
488
|
+
{ id: 2, src: '/media/two.mp4' },
|
|
489
|
+
{ id: 3, src: '/media/three.mp4' },
|
|
490
|
+
])
|
|
491
|
+
</script>
|
|
492
|
+
|
|
493
|
+
<template>
|
|
494
|
+
<ShortMediaFeed
|
|
495
|
+
ref="feed"
|
|
496
|
+
v-model:active-index="activeIndex"
|
|
497
|
+
:items="media"
|
|
498
|
+
:buffer="1"
|
|
499
|
+
height="100dvh"
|
|
500
|
+
item-key="id"
|
|
501
|
+
aria-label="Video feed"
|
|
502
|
+
>
|
|
503
|
+
<template #default="{ item, active }">
|
|
504
|
+
<video
|
|
505
|
+
:src="item.src"
|
|
506
|
+
:autoplay="active"
|
|
507
|
+
:muted="!active"
|
|
508
|
+
playsinline
|
|
509
|
+
controls
|
|
510
|
+
/>
|
|
511
|
+
</template>
|
|
512
|
+
|
|
513
|
+
<template #empty>No media found.</template>
|
|
514
|
+
</ShortMediaFeed>
|
|
515
|
+
</template>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
With `buffer="1"`, at most the active item, one previous item, and one next
|
|
519
|
+
item are mounted. Slot-local state is lost when an item leaves the buffer, so
|
|
520
|
+
keep persistent playback state in the parent.
|
|
521
|
+
|
|
522
|
+
#### Props
|
|
523
|
+
|
|
524
|
+
| Prop | Type | Default | Description |
|
|
525
|
+
| --- | --- | --- | --- |
|
|
526
|
+
| `items` | `readonly T[]` | required | Media data passed to the default slot. |
|
|
527
|
+
| `activeIndex` | `number` | `0` | Active zero-based index; supports `v-model:active-index`. |
|
|
528
|
+
| `buffer` | `number` | `1` | Extra mounted items before and after the active item. |
|
|
529
|
+
| `height` | `number \| string` | `"100dvh"` | Feed viewport height. |
|
|
530
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each item. |
|
|
531
|
+
| `ariaLabel` | `string` | `"Short media feed"` | Accessible label for the feed. |
|
|
532
|
+
| `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
|
|
533
|
+
| `loading` | `boolean` | `false` | Prevents duplicate `load-more` requests. |
|
|
534
|
+
| `loadMoreThreshold` | `number` | `2` | Remaining item count that triggers `load-more`. |
|
|
535
|
+
|
|
536
|
+
#### Slots and events
|
|
537
|
+
|
|
538
|
+
| Kind | Name | Payload/scope | Description |
|
|
539
|
+
| --- | --- | --- | --- |
|
|
540
|
+
| Slot | `default` | `{ item, index, active }` | Renders a mounted item; `active` marks the snapped item. |
|
|
541
|
+
| Slot | `empty` | none | Rendered when `items` is empty. |
|
|
542
|
+
| Event | `update:activeIndex` | `number` | Supports `v-model:active-index`. |
|
|
543
|
+
| Event | `change` | `{ index, item }` | The active item changes. |
|
|
544
|
+
| Event | `reach-start` | none | Navigation changes to the first item. |
|
|
545
|
+
| Event | `reach-end` | none | Navigation changes to the last item. |
|
|
546
|
+
| Event | `load-more` | none | Remaining items reach `loadMoreThreshold`. |
|
|
547
|
+
|
|
548
|
+
#### Exposed API and keyboard controls
|
|
549
|
+
|
|
550
|
+
`scrollToIndex(index, options?)` scrolls to an item using standard
|
|
551
|
+
`ScrollToOptions`. The viewport supports Arrow Up/Down, Page Up/Down, Home,
|
|
552
|
+
and End.
|
|
553
|
+
|
|
554
|
+
For an unbounded feed, page data with `load-more` and keep a reasonable item
|
|
555
|
+
window in the parent. Virtualization limits mounted DOM and media elements,
|
|
556
|
+
but it does not remove objects from `items`.
|
|
557
|
+
|
|
558
|
+
### VirtualCarousel
|
|
559
|
+
|
|
560
|
+
`VirtualCarousel` is a headless horizontal carousel with scroll snapping and
|
|
561
|
+
virtualized slides. `activeIndex` represents the first slide in the current
|
|
562
|
+
view.
|
|
563
|
+
|
|
564
|
+
#### Example
|
|
565
|
+
|
|
566
|
+
```vue
|
|
567
|
+
<script setup lang="ts">
|
|
568
|
+
import { ref } from 'vue'
|
|
569
|
+
import { VirtualCarousel, type VirtualCarouselExpose } from 'vue-virtual-flow'
|
|
570
|
+
|
|
571
|
+
const carousel = ref<VirtualCarouselExpose>()
|
|
572
|
+
const activeIndex = ref(0)
|
|
573
|
+
const products = ref(loadProducts())
|
|
574
|
+
</script>
|
|
575
|
+
|
|
576
|
+
<template>
|
|
577
|
+
<VirtualCarousel
|
|
578
|
+
ref="carousel"
|
|
579
|
+
v-model:active-index="activeIndex"
|
|
580
|
+
:items="products"
|
|
581
|
+
:slides-per-view="3"
|
|
582
|
+
:slides-to-scroll="1"
|
|
583
|
+
:gap="16"
|
|
584
|
+
:buffer="1"
|
|
585
|
+
:height="320"
|
|
586
|
+
item-key="id"
|
|
587
|
+
aria-label="Featured products"
|
|
588
|
+
>
|
|
589
|
+
<template #default="{ item, index, active, visible }">
|
|
590
|
+
<ProductCard
|
|
591
|
+
:product="item"
|
|
592
|
+
:position="index"
|
|
593
|
+
:active="active"
|
|
594
|
+
:visible="visible"
|
|
595
|
+
/>
|
|
596
|
+
</template>
|
|
597
|
+
|
|
598
|
+
<template #empty>No products.</template>
|
|
599
|
+
</VirtualCarousel>
|
|
600
|
+
|
|
601
|
+
<button @click="carousel?.previous()">Previous</button>
|
|
602
|
+
<button @click="carousel?.next()">Next</button>
|
|
603
|
+
</template>
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Slide width is calculated as:
|
|
607
|
+
|
|
608
|
+
```text
|
|
609
|
+
(viewport width - (slidesPerView - 1) × gap) / slidesPerView
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
`active` is true only for the first slide in the view. `visible` is true for
|
|
613
|
+
every slide in that view. With three visible slides and a buffer of one, up to
|
|
614
|
+
five slides are normally mounted in the middle of the collection.
|
|
615
|
+
|
|
616
|
+
#### Props
|
|
617
|
+
|
|
618
|
+
| Prop | Type | Default | Description |
|
|
619
|
+
| --- | --- | --- | --- |
|
|
620
|
+
| `items` | `readonly T[]` | required | Slide data passed to the default slot. |
|
|
621
|
+
| `activeIndex` | `number` | `0` | First visible slide; supports `v-model:active-index`. |
|
|
622
|
+
| `slidesPerView` | `number` | `1` | Number of slides displayed at once. |
|
|
623
|
+
| `slidesToScroll` | `number` | `1` | Slides moved by `next()` and `previous()`. |
|
|
624
|
+
| `gap` | `number` | `0` | Gap between slides in pixels. |
|
|
625
|
+
| `buffer` | `number` | `1` | Extra mounted slides before and after the visible group. |
|
|
626
|
+
| `height` | `number \| string` | `"auto"` | Carousel height. |
|
|
627
|
+
| `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key`, or index | Stable identity for each slide. |
|
|
628
|
+
| `ariaLabel` | `string` | `"Carousel"` | Accessible carousel label. |
|
|
629
|
+
| `hasMore` | `boolean` | `false` | Indicates that another page can be loaded. |
|
|
630
|
+
| `loading` | `boolean` | `false` | Prevents duplicate `load-more` requests. |
|
|
631
|
+
| `loadMoreThreshold` | `number` | `2` | Remaining slides after the visible group that trigger `load-more`. |
|
|
632
|
+
|
|
633
|
+
#### Slots and events
|
|
634
|
+
|
|
635
|
+
| Kind | Name | Payload/scope | Description |
|
|
636
|
+
| --- | --- | --- | --- |
|
|
637
|
+
| Slot | `default` | `{ item, index, active, visible }` | Renders each mounted slide. |
|
|
638
|
+
| Slot | `empty` | none | Rendered when `items` is empty. |
|
|
639
|
+
| Event | `update:activeIndex` | `number` | Supports `v-model:active-index`. |
|
|
640
|
+
| Event | `change` | `{ index, item }` | The first visible slide changes. |
|
|
641
|
+
| Event | `reach-start` | none | Navigation changes to the first position. |
|
|
642
|
+
| Event | `reach-end` | none | Navigation changes to the final valid position. |
|
|
643
|
+
| Event | `load-more` | none | Remaining slides reach `loadMoreThreshold`. |
|
|
644
|
+
|
|
645
|
+
#### Exposed API and keyboard controls
|
|
646
|
+
|
|
647
|
+
| Method | Description |
|
|
648
|
+
| --- | --- |
|
|
649
|
+
| `next(behavior?)` | Advances by `slidesToScroll`; defaults to smooth scrolling. |
|
|
650
|
+
| `previous(behavior?)` | Moves back by `slidesToScroll`. |
|
|
651
|
+
| `scrollToIndex(index, options?)` | Makes the index the first visible slide. |
|
|
652
|
+
|
|
653
|
+
The viewport supports Arrow Left/Right, Page Up/Down, Home, and End. A
|
|
654
|
+
long-distance smooth jump becomes immediate so virtualized gaps are not shown.
|
|
655
|
+
|
|
656
|
+
## Shared recipes
|
|
657
|
+
|
|
658
|
+
### Viewport height
|
|
659
|
+
|
|
660
|
+
Container-based components accept a number or CSS height string. Numbers are
|
|
661
|
+
converted to pixels. `height="fill"` is shorthand for `height="100%"`:
|
|
662
|
+
|
|
663
|
+
```vue
|
|
664
|
+
<div class="list-container">
|
|
665
|
+
<VirtualList :items="items" height="fill">
|
|
666
|
+
<template #default="{ item }"><ResultRow :item="item" /></template>
|
|
667
|
+
</VirtualList>
|
|
668
|
+
</div>
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
```css
|
|
672
|
+
.list-container {
|
|
673
|
+
height: 100%;
|
|
674
|
+
min-height: 0;
|
|
675
|
+
}
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
A percentage height only works when its parent has an explicit height.
|
|
679
|
+
Development builds warn when it resolves to `0px`.
|
|
680
|
+
`WindowDynamicVirtualScroll` uses the browser window and has no `height` prop.
|
|
681
|
+
|
|
682
|
+
### Stable item keys
|
|
683
|
+
|
|
684
|
+
For object items, components try `item.id` and then `item.key`. Primitive items
|
|
685
|
+
fall back to their index. Provide `itemKey` when the identifier has another
|
|
686
|
+
name:
|
|
687
|
+
|
|
688
|
+
```vue
|
|
689
|
+
<VirtualList :items="users" item-key="uuid" />
|
|
690
|
+
|
|
691
|
+
<VirtualList
|
|
692
|
+
:items="rows"
|
|
693
|
+
:item-key="(row) => `${row.accountId}:${row.sequence}`"
|
|
694
|
+
/>
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
Stable unique keys are essential when inserting, deleting, prepending, or
|
|
698
|
+
reordering. They also associate saved height measurements with the correct
|
|
699
|
+
variable-height item.
|
|
700
|
+
|
|
701
|
+
### Load more
|
|
702
|
+
|
|
703
|
+
List components use a pixel distance threshold. `ShortMediaFeed` and
|
|
704
|
+
`VirtualCarousel` use a remaining-item threshold.
|
|
705
|
+
|
|
706
|
+
```vue
|
|
707
|
+
<script setup lang="ts">
|
|
708
|
+
import { computed, ref } from 'vue'
|
|
709
|
+
|
|
710
|
+
const items = ref(loadFirstPage())
|
|
711
|
+
const loading = ref(false)
|
|
712
|
+
const hasMore = computed(() => items.value.length < 1_000)
|
|
713
|
+
|
|
714
|
+
async function loadMore() {
|
|
715
|
+
if (loading.value || !hasMore.value) return
|
|
716
|
+
loading.value = true
|
|
717
|
+
items.value.push(...(await fetchNextPage()))
|
|
718
|
+
loading.value = false
|
|
719
|
+
}
|
|
720
|
+
</script>
|
|
721
|
+
|
|
722
|
+
<template>
|
|
723
|
+
<VirtualList
|
|
724
|
+
:items="items"
|
|
725
|
+
:has-more="hasMore"
|
|
726
|
+
:loading="loading"
|
|
727
|
+
:load-more-threshold="400"
|
|
728
|
+
@load-more="loadMore"
|
|
729
|
+
>
|
|
730
|
+
<template #default="{ item }"><FeedCard :item="item" /></template>
|
|
731
|
+
<template #loading>Loading the next page…</template>
|
|
732
|
+
</VirtualList>
|
|
733
|
+
</template>
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
The event fires at most once for the current item count. Appending items allows
|
|
737
|
+
the next request. Keep `loading` true for the entire request and set `hasMore`
|
|
738
|
+
to false after the final page.
|
|
739
|
+
|
|
740
|
+
### Pull to refresh
|
|
741
|
+
|
|
742
|
+
Pull-to-refresh is available on `VirtualList`, `DynamicVirtualScroll`,
|
|
743
|
+
`VirtualScroll`, and `WindowDynamicVirtualScroll`:
|
|
744
|
+
|
|
745
|
+
```vue
|
|
746
|
+
<VirtualList
|
|
747
|
+
:items="items"
|
|
748
|
+
pull-to-refresh
|
|
749
|
+
:refreshing="refreshing"
|
|
750
|
+
:pull-refresh-threshold="72"
|
|
751
|
+
@refresh="refreshFirstPage"
|
|
752
|
+
>
|
|
753
|
+
<template #default="{ item }"><FeedCard :item="item" /></template>
|
|
754
|
+
<template #refresh="{ progress, refreshing: busy }">
|
|
755
|
+
{{ busy ? 'Refreshing…' : progress >= 1 ? 'Release' : 'Pull down' }}
|
|
756
|
+
</template>
|
|
757
|
+
</VirtualList>
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
The single-touch gesture begins only at the top. Set `refreshing` to true while
|
|
761
|
+
the async refresh runs, then return it to false to close the indicator.
|
|
762
|
+
|
|
763
|
+
## TypeScript exports
|
|
764
|
+
|
|
765
|
+
```ts
|
|
766
|
+
import type {
|
|
767
|
+
ChatVirtualScrollExpose,
|
|
768
|
+
ChatVirtualScrollProps,
|
|
769
|
+
DynamicVirtualScrollProps,
|
|
770
|
+
ItemKey,
|
|
771
|
+
ScrollAlignment,
|
|
772
|
+
ShortMediaFeedChangeEvent,
|
|
773
|
+
ShortMediaFeedExpose,
|
|
774
|
+
ShortMediaFeedProps,
|
|
775
|
+
VirtualCarouselChangeEvent,
|
|
776
|
+
VirtualCarouselExpose,
|
|
777
|
+
VirtualCarouselProps,
|
|
778
|
+
VirtualListExpose,
|
|
779
|
+
VirtualListProps,
|
|
780
|
+
VirtualScrollEvent,
|
|
781
|
+
VirtualScrollExpose,
|
|
782
|
+
VirtualScrollProps,
|
|
783
|
+
WindowDynamicVirtualScrollProps,
|
|
784
|
+
} from 'vue-virtual-flow'
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
## Development and release
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
bun install
|
|
791
|
+
bunx playwright install chromium firefox webkit
|
|
792
|
+
bun run dev
|
|
793
|
+
bun run check
|
|
794
|
+
```
|
|
795
|
+
|
|
796
|
+
The playground includes fixed-height, variable-height, window, chat,
|
|
797
|
+
load-more, short-media, and carousel demos. Before publishing, also run
|
|
798
|
+
`npm pack --dry-run`. GitHub releases are published through
|
|
799
|
+
`.github/workflows/publish.yml`.
|
|
800
|
+
|
|
801
|
+
## Contributing
|
|
802
|
+
|
|
803
|
+
Issues and pull requests are welcome. Read
|
|
804
|
+
[CONTRIBUTING.md](./CONTRIBUTING.md) before submitting a change.
|
|
805
|
+
|
|
806
|
+
## License
|
|
807
|
+
|
|
808
|
+
[MIT](./LICENSE)
|