@floor/vlist 0.5.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +839 -0
  3. package/dist/adapters/index.d.ts +20 -0
  4. package/dist/adapters/index.d.ts.map +1 -0
  5. package/dist/adapters/react.d.ts +119 -0
  6. package/dist/adapters/react.d.ts.map +1 -0
  7. package/dist/adapters/svelte.d.ts +198 -0
  8. package/dist/adapters/svelte.d.ts.map +1 -0
  9. package/dist/adapters/vue.d.ts +151 -0
  10. package/dist/adapters/vue.d.ts.map +1 -0
  11. package/dist/builder/context.d.ts +36 -0
  12. package/dist/builder/context.d.ts.map +1 -0
  13. package/dist/builder/core.d.ts +16 -0
  14. package/dist/builder/core.d.ts.map +1 -0
  15. package/dist/builder/data.d.ts +71 -0
  16. package/dist/builder/data.d.ts.map +1 -0
  17. package/dist/builder/index.d.ts +25 -0
  18. package/dist/builder/index.d.ts.map +1 -0
  19. package/dist/builder/index.js +1 -0
  20. package/dist/builder/types.d.ts +269 -0
  21. package/dist/builder/types.d.ts.map +1 -0
  22. package/dist/compression/index.js +1 -0
  23. package/dist/constants.d.ts +65 -0
  24. package/dist/constants.d.ts.map +1 -0
  25. package/dist/core/index.js +1 -0
  26. package/dist/core-light.d.ts +104 -0
  27. package/dist/core-light.d.ts.map +1 -0
  28. package/dist/core-light.js +1 -0
  29. package/dist/core.d.ts +129 -0
  30. package/dist/core.d.ts.map +1 -0
  31. package/dist/data/index.js +1 -0
  32. package/dist/events/emitter.d.ts +20 -0
  33. package/dist/events/emitter.d.ts.map +1 -0
  34. package/dist/events/index.d.ts +6 -0
  35. package/dist/events/index.d.ts.map +1 -0
  36. package/dist/grid/index.js +1 -0
  37. package/dist/groups/index.js +1 -0
  38. package/dist/index.d.ts +17 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +1 -0
  41. package/dist/plugins/compression/index.d.ts +10 -0
  42. package/dist/plugins/compression/index.d.ts.map +1 -0
  43. package/dist/plugins/compression/plugin.d.ts +42 -0
  44. package/dist/plugins/compression/plugin.d.ts.map +1 -0
  45. package/dist/plugins/data/index.d.ts +9 -0
  46. package/dist/plugins/data/index.d.ts.map +1 -0
  47. package/dist/plugins/data/manager.d.ts +103 -0
  48. package/dist/plugins/data/manager.d.ts.map +1 -0
  49. package/dist/plugins/data/placeholder.d.ts +62 -0
  50. package/dist/plugins/data/placeholder.d.ts.map +1 -0
  51. package/dist/plugins/data/plugin.d.ts +60 -0
  52. package/dist/plugins/data/plugin.d.ts.map +1 -0
  53. package/dist/plugins/data/sparse.d.ts +91 -0
  54. package/dist/plugins/data/sparse.d.ts.map +1 -0
  55. package/dist/plugins/grid/index.d.ts +9 -0
  56. package/dist/plugins/grid/index.d.ts.map +1 -0
  57. package/dist/plugins/grid/layout.d.ts +29 -0
  58. package/dist/plugins/grid/layout.d.ts.map +1 -0
  59. package/dist/plugins/grid/plugin.d.ts +48 -0
  60. package/dist/plugins/grid/plugin.d.ts.map +1 -0
  61. package/dist/plugins/grid/renderer.d.ts +55 -0
  62. package/dist/plugins/grid/renderer.d.ts.map +1 -0
  63. package/dist/plugins/grid/types.d.ts +71 -0
  64. package/dist/plugins/grid/types.d.ts.map +1 -0
  65. package/dist/plugins/groups/index.d.ts +10 -0
  66. package/dist/plugins/groups/index.d.ts.map +1 -0
  67. package/dist/plugins/groups/layout.d.ts +46 -0
  68. package/dist/plugins/groups/layout.d.ts.map +1 -0
  69. package/dist/plugins/groups/plugin.d.ts +63 -0
  70. package/dist/plugins/groups/plugin.d.ts.map +1 -0
  71. package/dist/plugins/groups/sticky.d.ts +33 -0
  72. package/dist/plugins/groups/sticky.d.ts.map +1 -0
  73. package/dist/plugins/groups/types.d.ts +86 -0
  74. package/dist/plugins/groups/types.d.ts.map +1 -0
  75. package/dist/plugins/scroll/controller.d.ts +121 -0
  76. package/dist/plugins/scroll/controller.d.ts.map +1 -0
  77. package/dist/plugins/scroll/index.d.ts +8 -0
  78. package/dist/plugins/scroll/index.d.ts.map +1 -0
  79. package/dist/plugins/scroll/plugin.d.ts +60 -0
  80. package/dist/plugins/scroll/plugin.d.ts.map +1 -0
  81. package/dist/plugins/scroll/scrollbar.d.ts +73 -0
  82. package/dist/plugins/scroll/scrollbar.d.ts.map +1 -0
  83. package/dist/plugins/selection/index.d.ts +7 -0
  84. package/dist/plugins/selection/index.d.ts.map +1 -0
  85. package/dist/plugins/selection/plugin.d.ts +44 -0
  86. package/dist/plugins/selection/plugin.d.ts.map +1 -0
  87. package/dist/plugins/selection/state.d.ts +102 -0
  88. package/dist/plugins/selection/state.d.ts.map +1 -0
  89. package/dist/plugins/snapshots/index.d.ts +8 -0
  90. package/dist/plugins/snapshots/index.d.ts.map +1 -0
  91. package/dist/plugins/snapshots/plugin.d.ts +44 -0
  92. package/dist/plugins/snapshots/plugin.d.ts.map +1 -0
  93. package/dist/plugins/window/index.d.ts +8 -0
  94. package/dist/plugins/window/index.d.ts.map +1 -0
  95. package/dist/plugins/window/plugin.d.ts +53 -0
  96. package/dist/plugins/window/plugin.d.ts.map +1 -0
  97. package/dist/react/index.js +1 -0
  98. package/dist/render/compression.d.ts +116 -0
  99. package/dist/render/compression.d.ts.map +1 -0
  100. package/dist/render/heights.d.ts +63 -0
  101. package/dist/render/heights.d.ts.map +1 -0
  102. package/dist/render/index.d.ts +9 -0
  103. package/dist/render/index.d.ts.map +1 -0
  104. package/dist/render/renderer.d.ts +103 -0
  105. package/dist/render/renderer.d.ts.map +1 -0
  106. package/dist/render/virtual.d.ts +139 -0
  107. package/dist/render/virtual.d.ts.map +1 -0
  108. package/dist/scroll/index.js +1 -0
  109. package/dist/selection/index.js +1 -0
  110. package/dist/snapshots/index.js +1 -0
  111. package/dist/svelte/index.js +1 -0
  112. package/dist/types.d.ts +559 -0
  113. package/dist/types.d.ts.map +1 -0
  114. package/dist/vlist-extras.css +1 -0
  115. package/dist/vlist.css +1 -0
  116. package/dist/vlist.d.ts +22 -0
  117. package/dist/vlist.d.ts.map +1 -0
  118. package/dist/vue/index.js +1 -0
  119. package/dist/window/index.js +1 -0
  120. package/package.json +137 -0
package/README.md ADDED
@@ -0,0 +1,839 @@
1
+ # vlist
2
+
3
+ Lightweight, high-performance virtual list with zero dependencies.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/vlist.svg)](https://www.npmjs.com/package/vlist)
6
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/vlist)](https://bundlephobia.com/package/vlist)
7
+ [![license](https://img.shields.io/npm/l/vlist.svg)](https://github.com/floor/vlist/blob/main/LICENSE)
8
+
9
+ ## Features
10
+
11
+ - 🪶 **Zero dependencies** - No external libraries required
12
+ - ⚡ **Blazing fast** - Only renders visible items with element pooling
13
+ - 🎯 **Simple API** - Easy to use with TypeScript support
14
+ - 📐 **Grid layout** - 2D virtualized grid with configurable columns and gap
15
+ - 📏 **Variable heights** - Fixed or per-item height via `(index) => number`
16
+ - 📜 **Infinite scroll** - Built-in async adapter support
17
+ - ✅ **Selection** - Single and multiple selection modes
18
+ - 📌 **Sticky headers** - Grouped lists with sticky section headers
19
+ - 🪟 **Window scrolling** - Document-level scrolling with `scrollElement: window`
20
+ - 🎨 **Customizable** - Beautiful, customizable styles
21
+ - ♿ **Accessible** - WAI-ARIA listbox pattern, `aria-setsize`/`aria-posinset`, `aria-activedescendant`, live region, keyboard navigation
22
+ - 🌊 **Smooth scrolling** - Animated `scrollToIndex` / `scrollToItem`
23
+ - 💾 **Scroll save/restore** - `getScrollSnapshot()` / `restoreScroll()` for SPA navigation
24
+ - 💬 **Reverse mode** - Chat UI support with auto-scroll, scroll-preserving prepend
25
+ - 🔌 **Framework adapters** - Thin wrappers for React, Vue, and Svelte (<1 KB each)
26
+ - 🌲 **Tree-shakeable** - Sub-module imports for smaller bundles
27
+
28
+ ## Sandbox & Documentation
29
+
30
+ Interactive examples and documentation are available at **[vlist.dev](https://vlist.dev)**.
31
+
32
+ | Example | Description |
33
+ |---------|-------------|
34
+ | [Basic](https://vlist.dev/sandbox/basic/) | Pure vanilla JS — no frameworks, no dependencies |
35
+ | [Core](https://vlist.dev/sandbox/core/) | Lightweight `vlist/core` — 7.3 KB, 83% smaller |
36
+ | [Grid](https://vlist.dev/sandbox/grid/) | 2D photo gallery with real photos from Lorem Picsum |
37
+ | [Variable Heights](https://vlist.dev/sandbox/variable-heights/) | Chat-style messages with 4 different item heights |
38
+ | [Reverse Chat](https://vlist.dev/sandbox/reverse-chat/) | Chat UI with reverse mode, prepend history, auto-scroll |
39
+ | [Selection](https://vlist.dev/sandbox/selection/) | Single/multiple selection with keyboard navigation |
40
+ | [Infinite Scroll](https://vlist.dev/sandbox/infinite-scroll/) | Async data loading with simulated API |
41
+ | [Million Items](https://vlist.dev/sandbox/million-items/) | Stress test with 1–5 million items |
42
+ | [Velocity Loading](https://vlist.dev/sandbox/velocity-loading/) | Velocity-based load skipping demo |
43
+ | [Sticky Headers](https://vlist.dev/sandbox/sticky-headers/) | Grouped contact list with sticky section headers |
44
+ | [Window Scroll](https://vlist.dev/sandbox/window-scroll/) | Document-level scrolling with `scrollElement: window` |
45
+ | [Scroll Restore](https://vlist.dev/sandbox/scroll-restore/) | Save/restore scroll position across SPA navigation |
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ npm install vlist
51
+ ```
52
+
53
+ ### Sub-module Imports
54
+
55
+ For smaller bundles, import only what you need:
56
+
57
+ ```typescript
58
+ import { createVList } from 'vlist' // full library (48.2 KB / 16.0 KB gzip)
59
+ import { createVList } from 'vlist/core' // lightweight core (7.8 KB / 3.2 KB gzip)
60
+ import { createGridLayout } from 'vlist/grid' // grid layout utilities only
61
+ import { createSparseStorage } from 'vlist/data' // data utilities only
62
+ import { getCompressionInfo } from 'vlist/compression' // compression utilities only
63
+ import { createSelectionState } from 'vlist/selection' // selection utilities only
64
+ import { createScrollController } from 'vlist/scroll' // scroll utilities only
65
+ import { createGroupLayout } from 'vlist/groups' // group/sticky header utilities only
66
+ ```
67
+
68
+ | Import | Minified | Gzipped | Description |
69
+ |--------|----------|---------|-------------|
70
+ | `vlist` | 48.2 KB | 16.0 KB | All features |
71
+ | **`vlist/core`** | **7.8 KB** | **3.2 KB** | **Lightweight — 83% smaller** |
72
+ | `vlist/data` | 9.2 KB | 3.8 KB | Sparse storage, placeholders, data manager |
73
+ | `vlist/scroll` | 6.0 KB | 2.3 KB | Scroll controller + custom scrollbar |
74
+ | `vlist/grid` | 4.1 KB | 1.9 KB | Grid layout + 2D renderer |
75
+ | `vlist/groups` | 3.6 KB | 1.4 KB | Group layout + sticky headers |
76
+ | `vlist/compression` | 2.6 KB | 1.1 KB | Large-list compression utilities |
77
+ | `vlist/selection` | 1.9 KB | 0.7 KB | Selection state management |
78
+
79
+ ### Framework Adapters
80
+
81
+ Thin wrappers for React, Vue, and Svelte — each under 1 KB:
82
+
83
+ ```typescript
84
+ import { useVList } from 'vlist/react' // React hook (0.7 KB / 0.4 KB gzip)
85
+ import { useVList } from 'vlist/vue' // Vue 3 composable (0.5 KB / 0.4 KB gzip)
86
+ import { vlist } from 'vlist/svelte' // Svelte action (0.3 KB / 0.2 KB gzip)
87
+ ```
88
+
89
+ | Import | Minified | Gzipped | Description |
90
+ |--------|----------|---------|-------------|
91
+ | `vlist/react` | 0.7 KB | 0.4 KB | `useVList` hook + `useVListEvent` |
92
+ | `vlist/vue` | 0.5 KB | 0.4 KB | `useVList` composable + `useVListEvent` |
93
+ | `vlist/svelte` | 0.3 KB | 0.2 KB | `vlist` action + `onVListEvent` |
94
+
95
+ Adapters manage the vlist lifecycle (create on mount, destroy on unmount) and sync items reactively. See [Framework Adapters](#framework-adapters) for full examples.
96
+
97
+ ## Quick Start
98
+
99
+ ```typescript
100
+ import { createVList } from 'vlist';
101
+ import 'vlist/styles';
102
+
103
+ const list = createVList({
104
+ container: '#my-list',
105
+ ariaLabel: 'Contact list',
106
+ item: {
107
+ height: 48,
108
+ template: (item) => `
109
+ <div class="item-content">
110
+ <img src="${item.avatar}" class="avatar" />
111
+ <span>${item.name}</span>
112
+ </div>
113
+ `,
114
+ },
115
+ items: [
116
+ { id: 1, name: 'Alice', avatar: '/avatars/alice.jpg' },
117
+ { id: 2, name: 'Bob', avatar: '/avatars/bob.jpg' },
118
+ // ... more items
119
+ ],
120
+ });
121
+ ```
122
+
123
+ ### Lightweight Core (7.3 KB)
124
+
125
+ If you don't need selection, groups, grid, compression, custom scrollbar, or async data adapters, use the lightweight core for an **83% smaller bundle**:
126
+
127
+ ```typescript
128
+ import { createVList } from 'vlist/core';
129
+ import 'vlist/styles';
130
+
131
+ const list = createVList({
132
+ container: '#my-list',
133
+ item: {
134
+ height: 48,
135
+ template: (item) => `<div>${item.name}</div>`,
136
+ },
137
+ items: myItems,
138
+ });
139
+
140
+ // Same core API: setItems, appendItems, scrollToIndex, events, etc.
141
+ list.on('item:click', ({ item }) => console.log(item));
142
+ list.scrollToIndex(50, { behavior: 'smooth' });
143
+ ```
144
+
145
+ The core entry supports fixed/variable heights, smooth `scrollToIndex`, all data methods (`setItems`, `appendItems`, `prependItems`, `updateItem`, `removeItem`), events, window scrolling, and ResizeObserver — everything you need for most use cases.
146
+
147
+ ## Configuration
148
+
149
+ ```typescript
150
+ interface VListConfig<T> {
151
+ // Required
152
+ container: HTMLElement | string; // Container element or selector
153
+ item: {
154
+ height: number | ((index: number) => number); // Fixed or variable height
155
+ template: ItemTemplate<T>; // Render function for each item
156
+ };
157
+
158
+ // Layout
159
+ layout?: 'list' | 'grid'; // Layout mode (default: 'list')
160
+ grid?: { // Grid config (required when layout: 'grid')
161
+ columns: number; // Number of columns
162
+ gap?: number; // Gap between items in px (default: 0)
163
+ };
164
+
165
+ // Data
166
+ items?: T[]; // Static items array
167
+ adapter?: VListAdapter<T>; // Async data adapter
168
+
169
+ // Scrolling
170
+ overscan?: number; // Extra items to render (default: 3)
171
+ scroll?: {
172
+ wheel?: boolean; // Enable mouse wheel (default: true)
173
+ wrap?: boolean; // Wrap around at boundaries (default: false)
174
+ scrollbar?: 'native' | 'none' // Scrollbar mode (default: custom)
175
+ | ScrollbarOptions; // or { autoHide, autoHideDelay, minThumbSize }
176
+ element?: Window; // Window scrolling mode
177
+ idleTimeout?: number; // Scroll idle detection in ms (default: 150)
178
+ };
179
+
180
+ // Features
181
+ selection?: SelectionConfig; // Selection configuration
182
+ groups?: GroupsConfig; // Sticky headers / grouped lists
183
+ loading?: LoadingConfig; // Velocity-based loading thresholds
184
+
185
+ // Chat UI
186
+ reverse?: boolean; // Reverse mode (start at bottom, auto-scroll)
187
+
188
+ // Appearance
189
+ classPrefix?: string; // CSS class prefix (default: 'vlist')
190
+ ariaLabel?: string; // Accessible label for the listbox
191
+ }
192
+ ```
193
+
194
+ ## Examples
195
+
196
+ ### Grid Layout
197
+
198
+ ```typescript
199
+ const grid = createVList({
200
+ container: '#gallery',
201
+ layout: 'grid',
202
+ grid: {
203
+ columns: 4,
204
+ gap: 8, // 8px gap between columns AND rows
205
+ },
206
+ item: {
207
+ height: 200,
208
+ template: (item) => `
209
+ <div class="card">
210
+ <img src="${item.thumbnail}" />
211
+ <span>${item.title}</span>
212
+ </div>
213
+ `,
214
+ },
215
+ items: photos,
216
+ });
217
+ ```
218
+
219
+ Grid mode virtualizes by **rows** — only visible rows are in the DOM. Each item is positioned with `translate(x, y)` for GPU-accelerated rendering. Compression applies to row count, not item count.
220
+
221
+ ### Variable Heights
222
+
223
+ ```typescript
224
+ const list = createVList({
225
+ container: '#messages',
226
+ item: {
227
+ height: (index) => messages[index].type === 'header' ? 32 : 64,
228
+ template: (item) => `<div class="message">${item.text}</div>`,
229
+ },
230
+ items: messages,
231
+ });
232
+ ```
233
+
234
+ Variable heights use a prefix-sum array for O(1) offset lookups and O(log n) binary search for index-at-offset.
235
+
236
+ ### Sticky Headers
237
+
238
+ ```typescript
239
+ const list = createVList({
240
+ container: '#contacts',
241
+ item: {
242
+ height: 56,
243
+ template: (item) => `<div>${item.name}</div>`,
244
+ },
245
+ items: contacts, // Must be pre-sorted by group
246
+ groups: {
247
+ getGroupForIndex: (index) => contacts[index].lastName[0],
248
+ headerHeight: 36,
249
+ headerTemplate: (group) => `<div class="section-header">${group}</div>`,
250
+ sticky: true, // Headers stick to the top (default: true)
251
+ },
252
+ });
253
+ ```
254
+
255
+ ### Window Scrolling
256
+
257
+ ```typescript
258
+ const list = createVList({
259
+ container: '#my-list',
260
+ scroll: { element: window }, // Use the browser's native scrollbar
261
+ item: {
262
+ height: 48,
263
+ template: (item) => `<div>${item.name}</div>`,
264
+ },
265
+ items: myItems,
266
+ });
267
+ ```
268
+
269
+ ### Wizard / Carousel (Wrap Navigation)
270
+
271
+ ```typescript
272
+ const wizard = createVList({
273
+ container: '#wizard',
274
+ scroll: { wheel: false, scrollbar: 'none', wrap: true },
275
+ item: {
276
+ height: 400,
277
+ template: (step) => `<div class="step">${step.content}</div>`,
278
+ },
279
+ items: steps,
280
+ });
281
+
282
+ let current = 0;
283
+
284
+ // No boundary checks needed — wrap handles it
285
+ btnNext.addEventListener('click', () => {
286
+ current++;
287
+ wizard.scrollToIndex(current, { align: 'start', behavior: 'smooth' });
288
+ });
289
+
290
+ btnPrev.addEventListener('click', () => {
291
+ current--;
292
+ wizard.scrollToIndex(current, { align: 'start', behavior: 'smooth' });
293
+ });
294
+ ```
295
+
296
+ ### Reverse Mode (Chat UI)
297
+
298
+ ```typescript
299
+ const chat = createVList({
300
+ container: '#messages',
301
+ reverse: true,
302
+ item: {
303
+ height: (index) => messages[index].type === 'image' ? 200 : 60,
304
+ template: (msg) => `
305
+ <div class="bubble bubble--${msg.sender}">
306
+ <span class="sender">${msg.sender}</span>
307
+ <p>${msg.text}</p>
308
+ </div>
309
+ `,
310
+ },
311
+ items: messages, // Chronological order (oldest first)
312
+ });
313
+
314
+ // New message arrives — auto-scrolls to bottom if user was at bottom
315
+ chat.appendItems([newMessage]);
316
+
317
+ // Load older messages — scroll position preserved (no jump)
318
+ chat.prependItems(olderMessages);
319
+ ```
320
+
321
+ Reverse mode starts scrolled to the bottom. `appendItems` auto-scrolls to show new messages when the user is at the bottom. `prependItems` adjusts the scroll position so older messages appear above without disrupting the current view. Works with both fixed and variable heights. Cannot be combined with `groups` or `grid`.
322
+
323
+ ### With Selection
324
+
325
+ ```typescript
326
+ const list = createVList({
327
+ container: '#my-list',
328
+ item: {
329
+ height: 56,
330
+ template: (item, index, { selected }) => `
331
+ <div class="item-content ${selected ? 'selected' : ''}">
332
+ <span>${item.name}</span>
333
+ ${selected ? '✓' : ''}
334
+ </div>
335
+ `,
336
+ },
337
+ items: users,
338
+ selection: {
339
+ mode: 'multiple', // 'none' | 'single' | 'multiple'
340
+ initial: [1, 2], // Initially selected IDs
341
+ },
342
+ });
343
+
344
+ // Listen for selection changes
345
+ list.on('selection:change', ({ selected, items }) => {
346
+ console.log('Selected:', selected);
347
+ });
348
+
349
+ // Programmatic selection
350
+ list.select(5);
351
+ list.deselect(1);
352
+ list.selectAll();
353
+ list.clearSelection();
354
+ ```
355
+
356
+ ### With Infinite Scroll
357
+
358
+ ```typescript
359
+ const list = createVList({
360
+ container: '#my-list',
361
+ item: {
362
+ height: 64,
363
+ template: (item) => `<div>${item.title}</div>`,
364
+ },
365
+ adapter: {
366
+ read: async ({ offset, limit }) => {
367
+ const response = await fetch(
368
+ `/api/items?offset=${offset}&limit=${limit}`
369
+ );
370
+ const data = await response.json();
371
+
372
+ return {
373
+ items: data.items,
374
+ total: data.total,
375
+ hasMore: data.hasMore,
376
+ };
377
+ },
378
+ },
379
+ });
380
+
381
+ // Listen for loading events
382
+ list.on('load:start', ({ offset, limit }) => {
383
+ console.log('Loading...', offset, limit);
384
+ });
385
+
386
+ list.on('load:end', ({ items, total }) => {
387
+ console.log('Loaded', items.length, 'of', total);
388
+ });
389
+ ```
390
+
391
+ ### Scroll Save/Restore
392
+
393
+ ```typescript
394
+ const list = createVList({
395
+ container: '#my-list',
396
+ item: {
397
+ height: 64,
398
+ template: (item) => `<div>${item.name}</div>`,
399
+ },
400
+ items: myItems,
401
+ selection: { mode: 'multiple' },
402
+ });
403
+
404
+ // Save — e.g. before navigating away
405
+ const snapshot = list.getScrollSnapshot();
406
+ // { index: 523, offsetInItem: 12, selectedIds: [3, 7, 42] }
407
+ sessionStorage.setItem('list-scroll', JSON.stringify(snapshot));
408
+
409
+ // Restore — e.g. after navigating back and recreating the list
410
+ const saved = JSON.parse(sessionStorage.getItem('list-scroll'));
411
+ list.restoreScroll(saved);
412
+ // Scroll position AND selection are perfectly restored
413
+ ```
414
+
415
+ ### Framework Adapters
416
+
417
+ vlist ships thin framework wrappers that handle lifecycle and reactive item syncing. The adapters are **mount-based** — vlist manages the DOM while the framework provides the container element.
418
+
419
+ #### React
420
+
421
+ ```tsx
422
+ import { useVList, useVListEvent } from 'vlist/react';
423
+
424
+ function UserList({ users }) {
425
+ const { containerRef, instanceRef } = useVList({
426
+ item: {
427
+ height: 48,
428
+ template: (user) => `<div class="user">${user.name}</div>`,
429
+ },
430
+ items: users,
431
+ selection: { mode: 'single' },
432
+ });
433
+
434
+ // Optional: subscribe to events with automatic cleanup
435
+ useVListEvent(instanceRef, 'selection:change', ({ selected }) => {
436
+ console.log('Selected:', selected);
437
+ });
438
+
439
+ return (
440
+ <div
441
+ ref={containerRef}
442
+ style={{ height: 400 }}
443
+ onClick={() => instanceRef.current?.scrollToIndex(0)}
444
+ />
445
+ );
446
+ }
447
+ ```
448
+
449
+ `useVList` returns:
450
+ - `containerRef` — attach to your container `<div>`
451
+ - `instanceRef` — ref to the `VList` instance (populated after mount)
452
+ - `getInstance()` — stable helper to access the instance
453
+
454
+ Items auto-sync when `config.items` changes by reference.
455
+
456
+ #### Vue
457
+
458
+ ```vue
459
+ <template>
460
+ <div ref="containerRef" style="height: 400px" />
461
+ </template>
462
+
463
+ <script setup lang="ts">
464
+ import { useVList, useVListEvent } from 'vlist/vue';
465
+ import { ref } from 'vue';
466
+
467
+ const users = ref([
468
+ { id: 1, name: 'Alice' },
469
+ { id: 2, name: 'Bob' },
470
+ ]);
471
+
472
+ const { containerRef, instance } = useVList({
473
+ item: {
474
+ height: 48,
475
+ template: (user) => `<div class="user">${user.name}</div>`,
476
+ },
477
+ items: users.value,
478
+ });
479
+
480
+ // Optional: subscribe to events with automatic cleanup
481
+ useVListEvent(instance, 'selection:change', ({ selected }) => {
482
+ console.log('Selected:', selected);
483
+ });
484
+
485
+ function jumpToTop() {
486
+ instance.value?.scrollToIndex(0);
487
+ }
488
+ </script>
489
+ ```
490
+
491
+ `useVList` accepts a plain config or a reactive `Ref<Config>`. When using a ref, items are watched and synced automatically.
492
+
493
+ #### Svelte
494
+
495
+ ```svelte
496
+ <script>
497
+ import { vlist, onVListEvent } from 'vlist/svelte';
498
+
499
+ let instance;
500
+ let unsubs = [];
501
+
502
+ const options = {
503
+ config: {
504
+ item: {
505
+ height: 48,
506
+ template: (user) => `<div class="user">${user.name}</div>`,
507
+ },
508
+ items: users,
509
+ selection: { mode: 'single' },
510
+ },
511
+ onInstance: (inst) => {
512
+ instance = inst;
513
+ unsubs.push(
514
+ onVListEvent(inst, 'selection:change', ({ selected }) => {
515
+ console.log('Selected:', selected);
516
+ })
517
+ );
518
+ },
519
+ };
520
+
521
+ import { onDestroy } from 'svelte';
522
+ onDestroy(() => unsubs.forEach(fn => fn()));
523
+ </script>
524
+
525
+ <div use:vlist={options} style="height: 400px" />
526
+ <button on:click={() => instance?.scrollToIndex(0)}>Jump to top</button>
527
+ ```
528
+
529
+ The `vlist` action follows the standard Svelte `use:` directive contract. It works with both Svelte 4 and 5 with zero Svelte imports. Pass reactive options via `$:` to trigger updates automatically.
530
+
531
+ ### With Custom Template
532
+
533
+ ```typescript
534
+ const list = createVList({
535
+ container: '#my-list',
536
+ item: {
537
+ height: 72,
538
+ template: (item, index, { selected, focused }) => {
539
+ // Return an HTMLElement for more control
540
+ const el = document.createElement('div');
541
+ el.className = 'item-content';
542
+ el.innerHTML = `
543
+ <img src="${item.avatar}" class="avatar avatar--large" />
544
+ <div class="item-details">
545
+ <div class="item-name">${item.name}</div>
546
+ <div class="item-email">${item.email}</div>
547
+ </div>
548
+ <div class="item-role">${item.role}</div>
549
+ `;
550
+ return el;
551
+ },
552
+ },
553
+ items: users,
554
+ });
555
+ ```
556
+
557
+ ## API Reference
558
+
559
+ ### Methods
560
+
561
+ #### Data Management
562
+
563
+ ```typescript
564
+ list.setItems(items: T[]) // Replace all items
565
+ list.appendItems(items: T[]) // Add items to end
566
+ list.prependItems(items: T[]) // Add items to start
567
+ list.updateItem(id, updates) // Update item by ID
568
+ list.removeItem(id) // Remove item by ID
569
+ list.reload() // Reload from adapter
570
+ ```
571
+
572
+ #### Scrolling
573
+
574
+ ```typescript
575
+ list.scrollToIndex(index, align?) // Scroll to index ('start' | 'center' | 'end')
576
+ list.scrollToIndex(index, options?) // Scroll with options (smooth scrolling)
577
+ list.scrollToItem(id, align?) // Scroll to item by ID
578
+ list.scrollToItem(id, options?) // Scroll to item with options
579
+ list.cancelScroll() // Cancel in-progress smooth scroll
580
+ list.getScrollPosition() // Get current scroll position
581
+ list.getScrollSnapshot() // Get snapshot for save/restore
582
+ list.restoreScroll(snapshot) // Restore position (and selection) from snapshot
583
+
584
+ // ScrollToOptions: { align?, behavior?: 'auto' | 'smooth', duration? }
585
+ // Example: list.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
586
+ // ScrollSnapshot: { index, offsetInItem, selectedIds? } — JSON-serializable
587
+ ```
588
+
589
+ #### Selection
590
+
591
+ ```typescript
592
+ list.select(...ids) // Select items
593
+ list.deselect(...ids) // Deselect items
594
+ list.toggleSelect(id) // Toggle selection
595
+ list.selectAll() // Select all
596
+ list.clearSelection() // Clear selection
597
+ list.getSelected() // Get selected IDs
598
+ list.getSelectedItems() // Get selected items
599
+ ```
600
+
601
+ #### Events
602
+
603
+ ```typescript
604
+ list.on(event, handler) // Subscribe to event
605
+ list.off(event, handler) // Unsubscribe from event
606
+ ```
607
+
608
+ #### Lifecycle
609
+
610
+ ```typescript
611
+ list.destroy() // Cleanup and remove
612
+ ```
613
+
614
+ ### Properties
615
+
616
+ ```typescript
617
+ list.element // Root DOM element
618
+ list.items // Current items (readonly)
619
+ list.total // Total item count
620
+ ```
621
+
622
+ ### Events
623
+
624
+ | Event | Payload | Description |
625
+ |-------|---------|-------------|
626
+ | `item:click` | `{ item, index, event }` | Item was clicked |
627
+ | `selection:change` | `{ selected, items }` | Selection changed |
628
+ | `scroll` | `{ scrollTop, direction }` | Scroll position changed |
629
+ | `range:change` | `{ range }` | Visible range changed |
630
+ | `resize` | `{ height, width }` | Container was resized |
631
+ | `load:start` | `{ offset, limit }` | Data loading started |
632
+ | `load:end` | `{ items, total }` | Data loading completed |
633
+ | `error` | `{ error, context }` | Error occurred |
634
+
635
+ ## Keyboard Navigation & Accessibility
636
+
637
+ vlist implements the [WAI-ARIA Listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) for full screen reader and keyboard support.
638
+
639
+ ### Keyboard Shortcuts
640
+
641
+ When selection is enabled, the list supports full keyboard navigation:
642
+
643
+ | Key | Action |
644
+ |-----|--------|
645
+ | `↑` / `↓` | Move focus up/down |
646
+ | `Home` | Move focus to first item |
647
+ | `End` | Move focus to last item |
648
+ | `Space` / `Enter` | Toggle selection on focused item |
649
+ | `Tab` | Move focus into / out of the list |
650
+
651
+ ### ARIA Attributes
652
+
653
+ | Attribute | Element | Purpose |
654
+ |-----------|---------|---------|
655
+ | `role="listbox"` | Root | Identifies the widget as a list of selectable items |
656
+ | `role="option"` | Each item | Identifies each item as a selectable option |
657
+ | `aria-setsize` | Each item | Total item count — screen readers announce "item 5 of 10,000" |
658
+ | `aria-posinset` | Each item | 1-based position within the list |
659
+ | `aria-activedescendant` | Root | Points to the focused item's ID for screen reader tracking |
660
+ | `aria-selected` | Each item | Reflects selection state |
661
+ | `aria-busy` | Root | Present during async data loading |
662
+ | `aria-label` | Root | Set via `ariaLabel` config option |
663
+
664
+ A visually-hidden **live region** (`aria-live="polite"`) announces selection changes (e.g., "3 items selected").
665
+
666
+ Each item receives a unique `id` (`vlist-{instance}-item-{index}`) safe for multiple lists per page.
667
+
668
+ > 📖 Full documentation: [docs/accessibility.md](docs/accessibility.md)
669
+
670
+ ## Styling
671
+
672
+ ### Default Styles
673
+
674
+ Import the default styles:
675
+
676
+ ```typescript
677
+ import 'vlist/styles';
678
+ ```
679
+
680
+ Optional extras (variants, loading states, animations):
681
+
682
+ ```typescript
683
+ import 'vlist/styles/extras';
684
+ ```
685
+
686
+ ### CSS Classes
687
+
688
+ The component uses these CSS class names:
689
+
690
+ - `.vlist` - Root container
691
+ - `.vlist-viewport` - Scrollable viewport
692
+ - `.vlist-content` - Content container (sets total height)
693
+ - `.vlist-items` - Items container
694
+ - `.vlist-item` - Individual item
695
+ - `.vlist-item--selected` - Selected item
696
+ - `.vlist-item--focused` - Focused item (keyboard nav)
697
+ - `.vlist--grid` - Grid layout modifier
698
+ - `.vlist-grid-item` - Grid item (positioned with `translate(x, y)`)
699
+ - `.vlist--grouped` - Grouped list modifier
700
+ - `.vlist-sticky-header` - Sticky header overlay
701
+ - `.vlist-live-region` - Visually-hidden live region for screen reader announcements
702
+ - `.vlist--scrolling` - Applied during active scroll (disables transitions)
703
+
704
+ ### Variants
705
+
706
+ Import `vlist/styles/extras` for these variant classes:
707
+
708
+ ```html
709
+ <!-- Compact spacing -->
710
+ <div class="vlist vlist--compact">...</div>
711
+
712
+ <!-- Comfortable spacing -->
713
+ <div class="vlist vlist--comfortable">...</div>
714
+
715
+ <!-- No borders -->
716
+ <div class="vlist vlist--borderless">...</div>
717
+
718
+ <!-- Striped rows -->
719
+ <div class="vlist vlist--striped">...</div>
720
+ ```
721
+
722
+ ### CSS Custom Properties
723
+
724
+ All visual aspects can be customized via CSS custom properties:
725
+
726
+ ```css
727
+ :root {
728
+ --vlist-bg: #ffffff;
729
+ --vlist-bg-hover: #f9fafb;
730
+ --vlist-bg-selected: #eff6ff;
731
+ --vlist-border: #e5e7eb;
732
+ --vlist-text: #111827;
733
+ --vlist-focus-ring: #3b82f6;
734
+ --vlist-item-padding-x: 1rem;
735
+ --vlist-item-padding-y: 0.75rem;
736
+ --vlist-border-radius: 0.5rem;
737
+ --vlist-transition-duration: 150ms;
738
+ }
739
+ ```
740
+
741
+ Dark mode is supported automatically via `prefers-color-scheme: dark` or the `.dark` class.
742
+
743
+ ## TypeScript
744
+
745
+ Full TypeScript support with generics:
746
+
747
+ ```typescript
748
+ interface User {
749
+ id: number;
750
+ name: string;
751
+ email: string;
752
+ }
753
+
754
+ const list = createVList<User>({
755
+ container: '#users',
756
+ item: {
757
+ height: 48,
758
+ template: (user) => `<div>${user.name} - ${user.email}</div>`,
759
+ },
760
+ items: users,
761
+ });
762
+
763
+ // Fully typed
764
+ list.on('item:click', ({ item }) => {
765
+ console.log(item.email); // TypeScript knows this is a User
766
+ });
767
+ ```
768
+
769
+ ## Performance
770
+
771
+ vlist is designed for maximum performance with extensive built-in optimizations:
772
+
773
+ - **Virtual rendering** - Only visible items + overscan buffer are in the DOM
774
+ - **Element pooling** - DOM elements are recycled via `createElementPool()`, reducing GC pressure
775
+ - **Zero-allocation scroll hot path** - No object allocations per scroll frame; in-place range mutation
776
+ - **RAF-throttled native scroll** - At most one scroll processing per animation frame
777
+ - **CSS containment** - `contain: layout style` on items container, `contain: content` + `will-change: transform` on items
778
+ - **Scroll transition suppression** - `.vlist--scrolling` class disables CSS transitions during active scroll
779
+ - **Circular buffer velocity tracker** - Pre-allocated buffer, zero allocations during scroll
780
+ - **Targeted keyboard focus render** - Arrow keys update only 2 affected items instead of all visible items
781
+ - **Batched LRU timestamps** - Single `Date.now()` per render cycle instead of per-item
782
+ - **DocumentFragment batching** - New elements appended in a single DOM operation
783
+ - **Split CSS** - Core styles (5.6 KB) separated from optional extras (1.8 KB)
784
+ - **Configurable velocity-based loading** - Skip, preload, or defer loading based on scroll speed
785
+ - **Compression for 1M+ items** - Automatic scroll space compression when content exceeds browser height limits
786
+
787
+ ### Benchmark Results
788
+
789
+ Measured in Chrome (10-core Mac, 60Hz display) via the [live benchmark page](https://vlist.dev/benchmarks/):
790
+
791
+ | Metric | 10K items | 1M items |
792
+ |--------|-----------|----------|
793
+ | Initial render | ~32ms | ~135ms |
794
+ | Scroll FPS | 60fps | 61fps |
795
+ | Frame budget (avg) | 2.1ms | 1.9ms |
796
+ | Frame budget (p95) | 4.2ms | 8.7ms |
797
+ | Dropped frames | 0% | 0% |
798
+ | scrollToIndex | ~166ms | ~82ms |
799
+ | Memory (scroll delta) | 0 MB | 0 MB |
800
+
801
+ Zero dropped frames and zero memory growth during sustained scrolling — even at 1M items.
802
+
803
+ For the full optimization guide, see [docs/optimization.md](docs/optimization.md).
804
+
805
+ ## Browser Support
806
+
807
+ - Chrome 60+
808
+ - Firefox 55+
809
+ - Safari 12+
810
+ - Edge 79+
811
+
812
+ ## Contributing
813
+
814
+ Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) first.
815
+
816
+ ```bash
817
+ # Install dependencies
818
+ bun install
819
+
820
+ # Run development build
821
+ bun run dev
822
+
823
+ # Run tests
824
+ bun test
825
+
826
+ # Type check
827
+ bun run typecheck
828
+
829
+ # Build for production
830
+ bun run build
831
+ ```
832
+
833
+ ## License
834
+
835
+ MIT © [Floor](https://github.com/floor)
836
+
837
+ ## Credits
838
+
839
+ Inspired by the [mtrl-addons](https://github.com/floor/mtrl-addons) vlist component.