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/README.vi.md ADDED
@@ -0,0 +1,805 @@
1
+ # vue-virtual-flow
2
+
3
+ [English](./README.md) | [Tiếng Việt](./README.vi.md)
4
+
5
+ Bộ component virtual scrolling nhỏ gọn, có type đầy đủ và hỗ trợ khả năng tiếp
6
+ cận cho Vue 3. Thư viện hỗ trợ danh sách chiều cao cố định hoặc động, cuộn theo
7
+ cửa sổ, giao diện chat, feed media ngắn và carousel ngang.
8
+
9
+ Chỉ các item cần thiết cho viewport hiện tại được mount, nhờ đó tập dữ liệu lớn
10
+ vẫn phản hồi nhanh mà component không áp đặt giao diện của từng item.
11
+
12
+ ## Mục lục
13
+
14
+ - [Cài đặt](#cài-đặt)
15
+ - [Bắt đầu nhanh](#bắt-đầu-nhanh)
16
+ - [Chọn component](#chọn-component)
17
+ - [Hướng dẫn từng component](#hướng-dẫn-từng-component)
18
+ - [VirtualList và DynamicVirtualScroll](#virtuallist-và-dynamicvirtualscroll)
19
+ - [VirtualScroll](#virtualscroll)
20
+ - [WindowDynamicVirtualScroll](#windowdynamicvirtualscroll)
21
+ - [ChatVirtualScroll](#chatvirtualscroll)
22
+ - [ShortMediaFeed](#shortmediafeed)
23
+ - [VirtualCarousel](#virtualcarousel)
24
+ - [Cách dùng chung](#cách-dùng-chung)
25
+ - [Chiều cao viewport](#chiều-cao-viewport)
26
+ - [Key ổn định cho item](#key-ổn-định-cho-item)
27
+ - [Tải thêm dữ liệu](#tải-thêm-dữ-liệu)
28
+ - [Kéo để làm mới](#kéo-để-làm-mới)
29
+ - [Các type được export](#các-type-được-export)
30
+ - [Phát triển và phát hành](#phát-triển-và-phát-hành)
31
+ - [Đóng góp](#đóng-góp)
32
+ - [Giấy phép](#giấy-phép)
33
+
34
+ ## Cài đặt
35
+
36
+ ```bash
37
+ npm install vue-virtual-flow
38
+ ```
39
+
40
+ Import stylesheet của thư viện một lần tại entry point của ứng dụng:
41
+
42
+ ```ts
43
+ import 'vue-virtual-flow/style.css'
44
+ ```
45
+
46
+ Package yêu cầu Vue `^3.4.0` và Node.js `>=18`.
47
+
48
+ ## Bắt đầu nhanh
49
+
50
+ Import component cục bộ:
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: `Người dùng ${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>Không tìm thấy người dùng.</template>
70
+ </VirtualList>
71
+ </template>
72
+ ```
73
+
74
+ Hoặc đăng ký toàn bộ component ở cấp global:
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
+ Plugin đăng ký `VirtualList`, `DynamicVirtualScroll`, `VirtualScroll`,
86
+ `WindowDynamicVirtualScroll`, `ChatVirtualScroll`, `ShortMediaFeed` và
87
+ `VirtualCarousel`.
88
+
89
+ ## Chọn component
90
+
91
+ | Component | Nên dùng khi | Viewport cuộn | Kích thước item |
92
+ | --- | --- | --- | --- |
93
+ | `VirtualList` | Cần danh sách đa dụng với cách dùng đơn giản nhất. | Container của component | Động, tự đo |
94
+ | `DynamicVirtualScroll` | Giống `VirtualList` nhưng tên gọi thể hiện rõ chiều cao động. | Container của component | Động, tự đo |
95
+ | `VirtualScroll` | Tất cả hàng có cùng chiều cao đã biết. | Container của component | Cố định |
96
+ | `WindowDynamicVirtualScroll` | Muốn trang cuộn thay vì tạo một container cuộn lồng bên trong. | Cửa sổ trình duyệt | Động, tự đo |
97
+ | `ChatVirtualScroll` | Tin nhắn mới được thêm ở cuối và lịch sử cũ được thêm ở đầu. | Container của component | Động, tự đo |
98
+ | `ShortMediaFeed` | Mỗi lần cần snap một item đầy viewport. | Container của component | Một viewport cho mỗi item |
99
+ | `VirtualCarousel` | Cần hiển thị và snap nhiều slide theo chiều ngang. | Container của component | Tính từ chiều rộng container |
100
+
101
+ ## Hướng dẫn từng component
102
+
103
+ ### VirtualList và DynamicVirtualScroll
104
+
105
+ `VirtualList` là alias của `DynamicVirtualScroll`. Hai tên này render cùng một
106
+ component và dùng chung API. Component phù hợp cho card, kết quả tìm kiếm,
107
+ activity feed hoặc danh sách có các hàng cao thấp khác nhau.
108
+
109
+ Ban đầu mỗi hàng dùng `estimatedItemSize`. Sau đó `ResizeObserver` thay giá trị
110
+ ước tính bằng chiều cao đã đo. Khi chiều cao của item phía trên viewport thay
111
+ đổi, component bù lại vị trí cuộn để tránh nội dung bị nhảy.
112
+
113
+ #### Ví dụ
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: 'Bài viết ngắn', body: 'Chỉ có một dòng.' },
127
+ {
128
+ id: 'p2',
129
+ title: 'Bài viết dài',
130
+ body: 'Nội dung xuống nhiều dòng làm cho hàng này có chiều cao khác.',
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="Danh sách bài viết"
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>Chưa có bài viết.</template>
159
+ </VirtualList>
160
+
161
+ <button @click="list?.scrollToIndex(1, { align: 'center' })">
162
+ Đi tới bài viết thứ hai
163
+ </button>
164
+ </template>
165
+ ```
166
+
167
+ Nên chọn giá trị ước tính gần với chiều cao trung bình sau khi render. Các hàng
168
+ không cần bằng đúng giá trị này; ước tính hợp lý chủ yếu giúp scrollbar ban đầu
169
+ và thao tác nhảy tới item chưa được đo chính xác hơn.
170
+
171
+ #### Props
172
+
173
+ | Prop | Kiểu | Mặc định | Mô tả |
174
+ | --- | --- | --- | --- |
175
+ | `items` | `readonly T[]` | bắt buộc | Dữ liệu truyền vào default slot. |
176
+ | `estimatedItemSize` | `number` | `48` | Chiều cao trung bình ban đầu của hàng, tính bằng pixel. |
177
+ | `height` | `number \| string` | `400` | Chiều cao viewport. Số được hiểu là pixel; `"fill"` tương đương `100%`. |
178
+ | `overscan` | `number` | `3` | Số hàng mount thêm trước và sau vùng đang hiển thị. |
179
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định dùng cho key và kết quả đo. |
180
+ | `ariaLabel` | `string` | `"Dynamic virtual list"` | Nhãn hỗ trợ khả năng tiếp cận. |
181
+ | `hasMore` | `boolean` | `false` | Cho biết vẫn còn trang dữ liệu tiếp theo. |
182
+ | `loading` | `boolean` | `false` | Ngăn request trùng và hiển thị hàng loading. |
183
+ | `loadingItemSize` | `number` | `estimatedItemSize` | Chiều cao dành cho hàng loading, tính bằng pixel. |
184
+ | `loadMoreThreshold` | `number` | `200` | Khoảng cách tới cuối danh sách để emit `load-more`, tính bằng pixel. |
185
+ | `pullToRefresh` | `boolean` | `false` | Bật thao tác kéo xuống ở đầu danh sách trên thiết bị cảm ứng. |
186
+ | `refreshing` | `boolean` | `false` | Giữ indicator mở trong lúc refresh bất đồng bộ. |
187
+ | `pullRefreshThreshold` | `number` | `64` | Khoảng kéo cần thiết để emit `refresh`, tính bằng pixel. |
188
+
189
+ #### Slots
190
+
191
+ | Slot | Scope | Mô tả |
192
+ | --- | --- | --- |
193
+ | `default` | `{ item, index }` | Render từng hàng đang được mount. |
194
+ | `empty` | không có | Render khi `items` rỗng và `loading` là false. |
195
+ | `loading` | không có | Hàng loading; mặc định là “Loading more…”. |
196
+ | `refresh` | `{ pullDistance, progress, refreshing }` | Indicator kéo để làm mới. `progress` được giới hạn từ `0` tới `1`. |
197
+
198
+ #### Events
199
+
200
+ | Event | Payload | Thời điểm emit |
201
+ | --- | --- | --- |
202
+ | `scroll` | `{ scrollTop, startIndex, endIndex }` | Khi container cuộn. `endIndex` là mốc không bao gồm item tại index đó. |
203
+ | `load-more` | không có | Gần cuối danh sách khi `hasMore` là true và `loading` là false. |
204
+ | `refresh` | không có | Khi kéo qua ngưỡng refresh rồi thả tay. |
205
+
206
+ #### API qua ref
207
+
208
+ | Method | Mô tả |
209
+ | --- | --- |
210
+ | `scrollTo(position, options?)` | Cuộn tới offset pixel bên trong danh sách. |
211
+ | `scrollToIndex(index, { align?, behavior? })` | Cuộn tới index bắt đầu từ 0. `align` nhận `start`, `center` hoặc `end`. |
212
+ | `scrollToTop(behavior?)` | Cuộn về đầu danh sách. |
213
+
214
+ Smooth scroll ở khoảng cách xa tự chuyển thành nhảy tức thời vì các hàng chưa
215
+ đo có thể làm thay đổi offset đích trong lúc cuộn.
216
+
217
+ ### VirtualScroll
218
+
219
+ Dùng `VirtualScroll` khi mọi hàng có cùng một chiều cao chính xác. Component
220
+ không cần đo từng hàng, nên rất phù hợp cho bảng, menu, log hoặc danh sách kết
221
+ quả dày đặc.
222
+
223
+ #### Ví dụ
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: `Hàng ${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="Kết quả"
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
+ Đi tới hàng 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
+ Hàng đã render phải giữ đúng `itemSize`. Nếu nội dung có thể xuống dòng hoặc
266
+ thay đổi kích thước, hãy dùng `VirtualList`.
267
+
268
+ #### Props
269
+
270
+ | Prop | Kiểu | Mặc định | Mô tả |
271
+ | --- | --- | --- | --- |
272
+ | `items` | `readonly T[]` | bắt buộc | Dữ liệu truyền vào default slot. |
273
+ | `itemSize` | `number` | bắt buộc | Chiều cao chính xác của mọi hàng, tính bằng pixel. |
274
+ | `height` | `number \| string` | `400` | Chiều cao viewport. Số được hiểu là pixel; `"fill"` tương đương `100%`. |
275
+ | `overscan` | `number` | `3` | Số hàng mount thêm trước và sau vùng đang hiển thị. |
276
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định của mỗi hàng. |
277
+ | `ariaLabel` | `string` | `"Virtual list"` | Nhãn hỗ trợ khả năng tiếp cận. |
278
+ | `hasMore` | `boolean` | `false` | Cho biết vẫn còn trang dữ liệu tiếp theo. |
279
+ | `loading` | `boolean` | `false` | Ngăn request trùng và hiển thị hàng loading. |
280
+ | `loadingItemSize` | `number` | `itemSize` | Chiều cao dành cho hàng loading, tính bằng pixel. |
281
+ | `loadMoreThreshold` | `number` | `200` | Khoảng cách tới cuối danh sách để emit `load-more`, tính bằng pixel. |
282
+ | `pullToRefresh` | `boolean` | `false` | Bật thao tác kéo xuống ở đầu danh sách. |
283
+ | `refreshing` | `boolean` | `false` | Giữ indicator mở trong lúc refresh bất đồng bộ. |
284
+ | `pullRefreshThreshold` | `number` | `64` | Khoảng kéo cần thiết để emit `refresh`, tính bằng pixel. |
285
+
286
+ #### Slots, events và API qua ref
287
+
288
+ - Slots: `default({ item, index })`, `empty`, `loading` và
289
+ `refresh({ pullDistance, progress, refreshing })`.
290
+ - Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more` và
291
+ `refresh`.
292
+ - Methods: `scrollTo`, `scrollToIndex` và `scrollToTop`.
293
+
294
+ ### WindowDynamicVirtualScroll
295
+
296
+ Dùng `WindowDynamicVirtualScroll` cho trang hoặc feed dài khi muốn cửa sổ
297
+ trình duyệt là viewport cuộn thay vì một phần tử bên trong. Component đo các
298
+ hàng có chiều cao động và theo dõi `window.scrollY` cùng
299
+ `window.innerHeight`. Component này không có prop `height`.
300
+
301
+ #### Ví dụ
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>Nội dung phía trước 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="Feed bài viết"
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>Nội dung phía sau virtual list</footer>
336
+ </template>
337
+ ```
338
+
339
+ #### Props
340
+
341
+ | Prop | Kiểu | Mặc định | Mô tả |
342
+ | --- | --- | --- | --- |
343
+ | `items` | `readonly T[]` | bắt buộc | Dữ liệu truyền vào default slot. |
344
+ | `estimatedItemSize` | `number` | `48` | Chiều cao trung bình ban đầu của hàng, tính bằng pixel. |
345
+ | `overscan` | `number` | `3` | Số hàng mount thêm trước và sau vùng đang hiển thị. |
346
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định dùng cho key và kết quả đo. |
347
+ | `ariaLabel` | `string` | `"Window dynamic virtual list"` | Nhãn hỗ trợ khả năng tiếp cận. |
348
+ | `hasMore` | `boolean` | `false` | Cho biết vẫn còn trang dữ liệu tiếp theo. |
349
+ | `loading` | `boolean` | `false` | Ngăn request trùng và hiển thị hàng loading. |
350
+ | `loadingItemSize` | `number` | `estimatedItemSize` | Chiều cao dành cho hàng loading, tính bằng pixel. |
351
+ | `loadMoreThreshold` | `number` | `200` | Khoảng cách giữa đáy cửa sổ và cuối danh sách để emit `load-more`. |
352
+ | `pullToRefresh` | `boolean` | `false` | Bật refresh khi trang đang ở `window.scrollY = 0`. |
353
+ | `refreshing` | `boolean` | `false` | Giữ indicator mở trong lúc refresh bất đồng bộ. |
354
+ | `pullRefreshThreshold` | `number` | `64` | Khoảng kéo cần thiết để emit `refresh`, tính bằng pixel. |
355
+
356
+ #### Slots, events và API qua ref
357
+
358
+ - Slots: `default({ item, index })`, `empty`, `loading` và
359
+ `refresh({ pullDistance, progress, refreshing })`.
360
+ - Events: `scroll({ scrollTop, startIndex, endIndex })`, `load-more` và
361
+ `refresh`. `scrollTop` được tính tương đối từ đầu component.
362
+ - Methods: `scrollTo`, `scrollToIndex` và `scrollToTop`. Các method này cuộn
363
+ cửa sổ trình duyệt; `scrollTo` nhận offset tương đối so với đầu danh sách.
364
+
365
+ ### ChatVirtualScroll
366
+
367
+ `ChatVirtualScroll` dành cho hội thoại có tin nhắn cao thấp khác nhau. Mặc định
368
+ component mở tại tin nhắn mới nhất, chỉ bám theo tin nhắn mới khi người dùng
369
+ đang gần cuối và giữ nguyên nội dung đang xem khi lịch sử cũ được thêm vào đầu.
370
+
371
+ #### Ví dụ
372
+
373
+ ```vue
374
+ <script setup lang="ts">
375
+ import { ref } from 'vue'
376
+ import {
377
+ ChatVirtualScroll,
378
+ type ChatVirtualScrollExpose,
379
+ } from 'vue-virtual-flow'
380
+
381
+ const chat = ref<ChatVirtualScrollExpose>()
382
+ const loadingOlder = ref(false)
383
+ const messages = ref(loadLatestMessages())
384
+ const hasOlder = ref(true)
385
+
386
+ async function loadOlder() {
387
+ if (loadingOlder.value || !hasOlder.value) return
388
+ loadingOlder.value = true
389
+ const page = await fetchOlderPage(messages.value[0]?.id)
390
+ messages.value.unshift(...page.items)
391
+ hasOlder.value = page.hasMore
392
+ loadingOlder.value = false
393
+ }
394
+ </script>
395
+
396
+ <template>
397
+ <ChatVirtualScroll
398
+ ref="chat"
399
+ :items="messages"
400
+ :estimated-item-size="72"
401
+ :height="600"
402
+ :has-older="hasOlder"
403
+ :loading-older="loadingOlder"
404
+ :load-older-threshold="120"
405
+ item-key="id"
406
+ aria-label="Hội thoại hỗ trợ"
407
+ @load-older="loadOlder"
408
+ >
409
+ <template #default="{ item }">
410
+ <MessageBubble :message="item" />
411
+ </template>
412
+
413
+ <template #loadingOlder>Đang tải tin nhắn cũ…</template>
414
+ <template #empty>Chưa có tin nhắn.</template>
415
+ </ChatVirtualScroll>
416
+
417
+ <button v-if="!chat?.isAtBottom" @click="chat?.scrollToBottom('smooth')">
418
+ Xem tin nhắn mới nhất
419
+ </button>
420
+ </template>
421
+ ```
422
+
423
+ Hãy thêm dữ liệu cũ vào đầu cùng một collection có key ổn định. Component tính
424
+ chiều cao được thêm phía trên viewport và khôi phục vị trí của người đọc.
425
+
426
+ #### Props
427
+
428
+ | Prop | Kiểu | Mặc định | Mô tả |
429
+ | --- | --- | --- | --- |
430
+ | `items` | `readonly T[]` | bắt buộc | Tin nhắn được sắp từ cũ nhất tới mới nhất. |
431
+ | `estimatedItemSize` | `number` | `48` | Chiều cao trung bình ban đầu của tin nhắn, tính bằng pixel. |
432
+ | `height` | `number \| string` | `400` | Chiều cao viewport. Số được hiểu là pixel; `"fill"` tương đương `100%`. |
433
+ | `overscan` | `number` | `5` | Số tin nhắn mount thêm trước và sau vùng đang hiển thị. |
434
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định, đặc biệt quan trọng khi thêm vào đầu. |
435
+ | `ariaLabel` | `string` | `"Chat messages"` | Nhãn hỗ trợ khả năng tiếp cận cho live log. |
436
+ | `stickToBottom` | `boolean` | `true` | Bám theo tin nhắn mới nếu hiện tại đang gần cuối. |
437
+ | `bottomThreshold` | `number` | `80` | Khoảng cách vẫn được xem là “ở cuối”, tính bằng pixel. |
438
+ | `initialScroll` | `"top" \| "bottom"` | `"bottom"` | Vị trí ban đầu sau khi mount. |
439
+ | `hasOlder` | `boolean` | `false` | Cho biết vẫn còn lịch sử cũ. |
440
+ | `loadingOlder` | `boolean` | `false` | Ngăn request trùng và hiển thị indicator phía trên. |
441
+ | `loadOlderThreshold` | `number` | `120` | Khoảng cách tới đầu để emit `load-older`, tính bằng pixel. |
442
+
443
+ #### Slots
444
+
445
+ | Slot | Scope | Mô tả |
446
+ | --- | --- | --- |
447
+ | `default` | `{ item, index }` | Render từng tin nhắn đang được mount. |
448
+ | `empty` | không có | Render khi chưa có tin nhắn. |
449
+ | `loadingOlder` | không có | Status sticky phía trên; mặc định “Loading older messages…”. |
450
+
451
+ #### Events
452
+
453
+ | Event | Payload | Thời điểm emit |
454
+ | --- | --- | --- |
455
+ | `scroll` | `{ scrollTop, startIndex, endIndex }` | Khi container cuộn. |
456
+ | `load-older` | không có | Gần đầu khi `hasOlder` là true và `loadingOlder` là false. |
457
+ | `bottom-change` | `boolean` | Khi viewport đi vào hoặc rời ngưỡng ở cuối. |
458
+
459
+ #### API qua ref
460
+
461
+ | Member | Mô tả |
462
+ | --- | --- |
463
+ | `isAtBottom` | Boolean readonly cho biết người dùng có đang gần cuối hay không. |
464
+ | `scrollTo(position, options?)` | Cuộn tới offset pixel. |
465
+ | `scrollToIndex(index, { align?, behavior? })` | Cuộn tới index tin nhắn bắt đầu từ 0. |
466
+ | `scrollToTop(behavior?)` | Cuộn tới tin nhắn cũ nhất đã tải. |
467
+ | `scrollToBottom(behavior?)` | Cuộn tới tin nhắn mới nhất đã tải. |
468
+
469
+ ### ShortMediaFeed
470
+
471
+ `ShortMediaFeed` là viewport headless có scroll snap theo chiều dọc cho reels,
472
+ stories hoặc media ngắn. Mỗi item chiếm trọn một viewport. Component quản lý
473
+ việc mount và điều hướng; slot của bạn chịu trách nhiệm cho video, hình ảnh,
474
+ playback, âm thanh và các nút điều khiển.
475
+
476
+ #### Ví dụ
477
+
478
+ ```vue
479
+ <script setup lang="ts">
480
+ import { ref } from 'vue'
481
+ import { ShortMediaFeed, type ShortMediaFeedExpose } from 'vue-virtual-flow'
482
+
483
+ const feed = ref<ShortMediaFeedExpose>()
484
+ const activeIndex = ref(0)
485
+ const media = ref([
486
+ { id: 1, src: '/media/one.mp4' },
487
+ { id: 2, src: '/media/two.mp4' },
488
+ { id: 3, src: '/media/three.mp4' },
489
+ ])
490
+ </script>
491
+
492
+ <template>
493
+ <ShortMediaFeed
494
+ ref="feed"
495
+ v-model:active-index="activeIndex"
496
+ :items="media"
497
+ :buffer="1"
498
+ height="100dvh"
499
+ item-key="id"
500
+ aria-label="Feed video"
501
+ >
502
+ <template #default="{ item, active }">
503
+ <video
504
+ :src="item.src"
505
+ :autoplay="active"
506
+ :muted="!active"
507
+ playsinline
508
+ controls
509
+ />
510
+ </template>
511
+
512
+ <template #empty>Không có media.</template>
513
+ </ShortMediaFeed>
514
+ </template>
515
+ ```
516
+
517
+ Với `buffer="1"`, component chỉ mount tối đa item active, một item phía trước
518
+ và một item phía sau. State cục bộ trong slot bị mất khi item rời buffer, vì
519
+ vậy hãy lưu playback state cần duy trì ở component cha.
520
+
521
+ #### Props
522
+
523
+ | Prop | Kiểu | Mặc định | Mô tả |
524
+ | --- | --- | --- | --- |
525
+ | `items` | `readonly T[]` | bắt buộc | Dữ liệu media truyền vào default slot. |
526
+ | `activeIndex` | `number` | `0` | Index active bắt đầu từ 0; hỗ trợ `v-model:active-index`. |
527
+ | `buffer` | `number` | `1` | Số item mount thêm trước và sau item active. |
528
+ | `height` | `number \| string` | `"100dvh"` | Chiều cao viewport của feed. |
529
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định cho mỗi item. |
530
+ | `ariaLabel` | `string` | `"Short media feed"` | Nhãn hỗ trợ khả năng tiếp cận. |
531
+ | `hasMore` | `boolean` | `false` | Cho biết vẫn còn trang dữ liệu tiếp theo. |
532
+ | `loading` | `boolean` | `false` | Ngăn request `load-more` bị lặp. |
533
+ | `loadMoreThreshold` | `number` | `2` | Số item còn lại để kích hoạt `load-more`. |
534
+
535
+ #### Slots và events
536
+
537
+ | Loại | Tên | Payload/scope | Mô tả |
538
+ | --- | --- | --- | --- |
539
+ | Slot | `default` | `{ item, index, active }` | Render item đang mount; `active` đánh dấu item đang snap. |
540
+ | Slot | `empty` | không có | Render khi `items` rỗng. |
541
+ | Event | `update:activeIndex` | `number` | Hỗ trợ `v-model:active-index`. |
542
+ | Event | `change` | `{ index, item }` | Khi item active thay đổi. |
543
+ | Event | `reach-start` | không có | Khi điều hướng chuyển tới item đầu. |
544
+ | Event | `reach-end` | không có | Khi điều hướng chuyển tới item cuối. |
545
+ | Event | `load-more` | không có | Khi số item còn lại đạt `loadMoreThreshold`. |
546
+
547
+ #### API qua ref và bàn phím
548
+
549
+ `scrollToIndex(index, options?)` cuộn tới item bằng `ScrollToOptions` tiêu
550
+ chuẩn. Viewport hỗ trợ Arrow Up/Down, Page Up/Down, Home và End.
551
+
552
+ Với feed không giới hạn, hãy tải theo trang bằng `load-more` và chỉ giữ một cửa
553
+ sổ dữ liệu hợp lý ở component cha. Virtualization giới hạn DOM và media element
554
+ đang mount, nhưng không xóa object khỏi `items`.
555
+
556
+ ### VirtualCarousel
557
+
558
+ `VirtualCarousel` là carousel headless có scroll snap ngang và virtualize các
559
+ slide. `activeIndex` là index của slide đầu tiên trong vùng nhìn hiện tại.
560
+
561
+ #### Ví dụ
562
+
563
+ ```vue
564
+ <script setup lang="ts">
565
+ import { ref } from 'vue'
566
+ import { VirtualCarousel, type VirtualCarouselExpose } from 'vue-virtual-flow'
567
+
568
+ const carousel = ref<VirtualCarouselExpose>()
569
+ const activeIndex = ref(0)
570
+ const products = ref(loadProducts())
571
+ </script>
572
+
573
+ <template>
574
+ <VirtualCarousel
575
+ ref="carousel"
576
+ v-model:active-index="activeIndex"
577
+ :items="products"
578
+ :slides-per-view="3"
579
+ :slides-to-scroll="1"
580
+ :gap="16"
581
+ :buffer="1"
582
+ :height="320"
583
+ item-key="id"
584
+ aria-label="Sản phẩm nổi bật"
585
+ >
586
+ <template #default="{ item, index, active, visible }">
587
+ <ProductCard
588
+ :product="item"
589
+ :position="index"
590
+ :active="active"
591
+ :visible="visible"
592
+ />
593
+ </template>
594
+
595
+ <template #empty>Không có sản phẩm.</template>
596
+ </VirtualCarousel>
597
+
598
+ <button @click="carousel?.previous()">Trước</button>
599
+ <button @click="carousel?.next()">Sau</button>
600
+ </template>
601
+ ```
602
+
603
+ Chiều rộng slide được tính theo công thức:
604
+
605
+ ```text
606
+ (chiều rộng viewport - (slidesPerView - 1) × gap) / slidesPerView
607
+ ```
608
+
609
+ `active` chỉ là true với slide đầu tiên trong vùng nhìn. `visible` là true với
610
+ mọi slide thuộc vùng nhìn đó. Khi hiển thị ba slide và buffer bằng một, thông
611
+ thường tối đa năm slide được mount ở giữa collection.
612
+
613
+ #### Props
614
+
615
+ | Prop | Kiểu | Mặc định | Mô tả |
616
+ | --- | --- | --- | --- |
617
+ | `items` | `readonly T[]` | bắt buộc | Dữ liệu slide truyền vào default slot. |
618
+ | `activeIndex` | `number` | `0` | Slide đầu tiên đang hiển thị; hỗ trợ `v-model:active-index`. |
619
+ | `slidesPerView` | `number` | `1` | Số slide hiển thị cùng lúc. |
620
+ | `slidesToScroll` | `number` | `1` | Số slide di chuyển bởi `next()` và `previous()`. |
621
+ | `gap` | `number` | `0` | Khoảng cách giữa các slide, tính bằng pixel. |
622
+ | `buffer` | `number` | `1` | Số slide mount thêm trước và sau nhóm đang hiển thị. |
623
+ | `height` | `number \| string` | `"auto"` | Chiều cao carousel. |
624
+ | `itemKey` | `keyof T \| (item, index) => PropertyKey` | `id`, `key` hoặc index | Định danh ổn định cho mỗi slide. |
625
+ | `ariaLabel` | `string` | `"Carousel"` | Nhãn hỗ trợ khả năng tiếp cận. |
626
+ | `hasMore` | `boolean` | `false` | Cho biết vẫn còn trang dữ liệu tiếp theo. |
627
+ | `loading` | `boolean` | `false` | Ngăn request `load-more` bị lặp. |
628
+ | `loadMoreThreshold` | `number` | `2` | Số slide còn lại sau vùng nhìn để kích hoạt `load-more`. |
629
+
630
+ #### Slots và events
631
+
632
+ | Loại | Tên | Payload/scope | Mô tả |
633
+ | --- | --- | --- | --- |
634
+ | Slot | `default` | `{ item, index, active, visible }` | Render từng slide đang mount. |
635
+ | Slot | `empty` | không có | Render khi `items` rỗng. |
636
+ | Event | `update:activeIndex` | `number` | Hỗ trợ `v-model:active-index`. |
637
+ | Event | `change` | `{ index, item }` | Khi slide đầu tiên trong vùng nhìn thay đổi. |
638
+ | Event | `reach-start` | không có | Khi điều hướng chuyển tới vị trí đầu. |
639
+ | Event | `reach-end` | không có | Khi điều hướng chuyển tới vị trí đầu-slide hợp lệ cuối cùng. |
640
+ | Event | `load-more` | không có | Khi số slide còn lại đạt `loadMoreThreshold`. |
641
+
642
+ #### API qua ref và bàn phím
643
+
644
+ | Method | Mô tả |
645
+ | --- | --- |
646
+ | `next(behavior?)` | Tiến thêm `slidesToScroll` slide; mặc định cuộn mượt. |
647
+ | `previous(behavior?)` | Lùi lại `slidesToScroll` slide. |
648
+ | `scrollToIndex(index, options?)` | Đưa index thành slide đầu tiên trong vùng nhìn. |
649
+
650
+ Viewport hỗ trợ Arrow Left/Right, Page Up/Down, Home và End. Smooth scroll ở
651
+ khoảng cách xa tự chuyển thành tức thời để không lộ khoảng trống virtualized.
652
+
653
+ ## Cách dùng chung
654
+
655
+ ### Chiều cao viewport
656
+
657
+ Các component dùng container nhận một số hoặc chuỗi chiều cao CSS. Giá trị số
658
+ được đổi thành pixel. `height="fill"` là cách viết ngắn của
659
+ `height="100%"`:
660
+
661
+ ```vue
662
+ <div class="list-container">
663
+ <VirtualList :items="items" height="fill">
664
+ <template #default="{ item }"><ResultRow :item="item" /></template>
665
+ </VirtualList>
666
+ </div>
667
+ ```
668
+
669
+ ```css
670
+ .list-container {
671
+ height: 100%;
672
+ min-height: 0;
673
+ }
674
+ ```
675
+
676
+ Chiều cao phần trăm chỉ hoạt động khi thẻ cha có chiều cao xác định. Bản
677
+ development cảnh báo nếu viewport được tính thành `0px`.
678
+ `WindowDynamicVirtualScroll` dùng cửa sổ trình duyệt nên không có prop
679
+ `height`.
680
+
681
+ ### Key ổn định cho item
682
+
683
+ Với object, component tự thử `item.id` rồi tới `item.key`. Item nguyên thủy dùng
684
+ index làm fallback. Hãy truyền `itemKey` nếu định danh có tên khác:
685
+
686
+ ```vue
687
+ <VirtualList :items="users" item-key="uuid" />
688
+
689
+ <VirtualList
690
+ :items="rows"
691
+ :item-key="(row) => `${row.accountId}:${row.sequence}`"
692
+ />
693
+ ```
694
+
695
+ Key duy nhất và ổn định rất quan trọng khi thêm, xóa, thêm vào đầu hoặc sắp xếp
696
+ lại dữ liệu. Key cũng gắn kết quả đo chiều cao với đúng item.
697
+
698
+ ### Tải thêm dữ liệu
699
+
700
+ Các component dạng list dùng ngưỡng khoảng cách pixel. `ShortMediaFeed` và
701
+ `VirtualCarousel` dùng ngưỡng số item còn lại.
702
+
703
+ ```vue
704
+ <script setup lang="ts">
705
+ import { computed, ref } from 'vue'
706
+
707
+ const items = ref(loadFirstPage())
708
+ const loading = ref(false)
709
+ const hasMore = computed(() => items.value.length < 1_000)
710
+
711
+ async function loadMore() {
712
+ if (loading.value || !hasMore.value) return
713
+ loading.value = true
714
+ items.value.push(...(await fetchNextPage()))
715
+ loading.value = false
716
+ }
717
+ </script>
718
+
719
+ <template>
720
+ <VirtualList
721
+ :items="items"
722
+ :has-more="hasMore"
723
+ :loading="loading"
724
+ :load-more-threshold="400"
725
+ @load-more="loadMore"
726
+ >
727
+ <template #default="{ item }"><FeedCard :item="item" /></template>
728
+ <template #loading>Đang tải trang tiếp theo…</template>
729
+ </VirtualList>
730
+ </template>
731
+ ```
732
+
733
+ Event chỉ emit tối đa một lần với số lượng item hiện tại. Nối thêm item sẽ cho
734
+ phép request tiếp theo. Giữ `loading` là true trong toàn bộ request và đặt
735
+ `hasMore` thành false sau trang cuối.
736
+
737
+ ### Kéo để làm mới
738
+
739
+ Pull-to-refresh có trên `VirtualList`, `DynamicVirtualScroll`, `VirtualScroll`
740
+ và `WindowDynamicVirtualScroll`:
741
+
742
+ ```vue
743
+ <VirtualList
744
+ :items="items"
745
+ pull-to-refresh
746
+ :refreshing="refreshing"
747
+ :pull-refresh-threshold="72"
748
+ @refresh="refreshFirstPage"
749
+ >
750
+ <template #default="{ item }"><FeedCard :item="item" /></template>
751
+ <template #refresh="{ progress, refreshing: busy }">
752
+ {{ busy ? 'Đang làm mới…' : progress >= 1 ? 'Thả để làm mới' : 'Kéo xuống' }}
753
+ </template>
754
+ </VirtualList>
755
+ ```
756
+
757
+ Thao tác một ngón chỉ bắt đầu khi đang ở đầu danh sách. Đặt `refreshing` thành
758
+ true trong lúc chạy refresh bất đồng bộ, sau đó trả về false để đóng indicator.
759
+
760
+ ## Các type được export
761
+
762
+ ```ts
763
+ import type {
764
+ ChatVirtualScrollExpose,
765
+ ChatVirtualScrollProps,
766
+ DynamicVirtualScrollProps,
767
+ ItemKey,
768
+ ScrollAlignment,
769
+ ShortMediaFeedChangeEvent,
770
+ ShortMediaFeedExpose,
771
+ ShortMediaFeedProps,
772
+ VirtualCarouselChangeEvent,
773
+ VirtualCarouselExpose,
774
+ VirtualCarouselProps,
775
+ VirtualListExpose,
776
+ VirtualListProps,
777
+ VirtualScrollEvent,
778
+ VirtualScrollExpose,
779
+ VirtualScrollProps,
780
+ WindowDynamicVirtualScrollProps,
781
+ } from 'vue-virtual-flow'
782
+ ```
783
+
784
+ ## Phát triển và phát hành
785
+
786
+ ```bash
787
+ bun install
788
+ bunx playwright install chromium firefox webkit
789
+ bun run dev
790
+ bun run check
791
+ ```
792
+
793
+ Playground có các demo riêng cho chiều cao cố định, chiều cao động, cuộn theo
794
+ cửa sổ, chat, load-more, short-media và carousel. Trước khi publish, hãy chạy
795
+ thêm `npm pack --dry-run`. GitHub Release được publish qua
796
+ `.github/workflows/publish.yml`.
797
+
798
+ ## Đóng góp
799
+
800
+ Mọi issue và pull request đều được chào đón. Vui lòng đọc
801
+ [CONTRIBUTING.md](./CONTRIBUTING.md) trước khi gửi thay đổi.
802
+
803
+ ## Giấy phép
804
+
805
+ [MIT](./LICENSE)