@floor/vlist 0.5.8 → 0.6.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 (93) hide show
  1. package/README.md +787 -611
  2. package/dist/async/index.js +1 -0
  3. package/dist/builder/context.d.ts +3 -3
  4. package/dist/builder/context.d.ts.map +1 -1
  5. package/dist/builder/core.d.ts +1 -1
  6. package/dist/builder/core.d.ts.map +1 -1
  7. package/dist/builder/types.d.ts +3 -3
  8. package/dist/builder/types.d.ts.map +1 -1
  9. package/dist/core/full.d.ts +22 -0
  10. package/dist/core/full.d.ts.map +1 -0
  11. package/dist/core/lite.d.ts +129 -0
  12. package/dist/core/lite.d.ts.map +1 -0
  13. package/dist/core/minimal.d.ts +104 -0
  14. package/dist/core/minimal.d.ts.map +1 -0
  15. package/dist/features/async/index.d.ts +9 -0
  16. package/dist/features/async/index.d.ts.map +1 -0
  17. package/dist/features/async/manager.d.ts +103 -0
  18. package/dist/features/async/manager.d.ts.map +1 -0
  19. package/dist/features/async/placeholder.d.ts +62 -0
  20. package/dist/features/async/placeholder.d.ts.map +1 -0
  21. package/dist/features/async/plugin.d.ts +60 -0
  22. package/dist/features/async/plugin.d.ts.map +1 -0
  23. package/dist/features/async/sparse.d.ts +91 -0
  24. package/dist/features/async/sparse.d.ts.map +1 -0
  25. package/dist/features/grid/index.d.ts +9 -0
  26. package/dist/features/grid/index.d.ts.map +1 -0
  27. package/dist/features/grid/layout.d.ts +29 -0
  28. package/dist/features/grid/layout.d.ts.map +1 -0
  29. package/dist/features/grid/plugin.d.ts +48 -0
  30. package/dist/features/grid/plugin.d.ts.map +1 -0
  31. package/dist/features/grid/renderer.d.ts +55 -0
  32. package/dist/features/grid/renderer.d.ts.map +1 -0
  33. package/dist/features/grid/types.d.ts +71 -0
  34. package/dist/features/grid/types.d.ts.map +1 -0
  35. package/dist/features/page/index.d.ts +8 -0
  36. package/dist/features/page/index.d.ts.map +1 -0
  37. package/dist/features/page/plugin.d.ts +53 -0
  38. package/dist/features/page/plugin.d.ts.map +1 -0
  39. package/dist/features/scale/index.d.ts +10 -0
  40. package/dist/features/scale/index.d.ts.map +1 -0
  41. package/dist/features/scale/plugin.d.ts +42 -0
  42. package/dist/features/scale/plugin.d.ts.map +1 -0
  43. package/dist/features/scrollbar/controller.d.ts +121 -0
  44. package/dist/features/scrollbar/controller.d.ts.map +1 -0
  45. package/dist/features/scrollbar/index.d.ts +8 -0
  46. package/dist/features/scrollbar/index.d.ts.map +1 -0
  47. package/dist/features/scrollbar/plugin.d.ts +60 -0
  48. package/dist/features/scrollbar/plugin.d.ts.map +1 -0
  49. package/dist/features/scrollbar/scrollbar.d.ts +73 -0
  50. package/dist/features/scrollbar/scrollbar.d.ts.map +1 -0
  51. package/dist/features/sections/index.d.ts +10 -0
  52. package/dist/features/sections/index.d.ts.map +1 -0
  53. package/dist/features/sections/layout.d.ts +46 -0
  54. package/dist/features/sections/layout.d.ts.map +1 -0
  55. package/dist/features/sections/plugin.d.ts +64 -0
  56. package/dist/features/sections/plugin.d.ts.map +1 -0
  57. package/dist/features/sections/sticky.d.ts +33 -0
  58. package/dist/features/sections/sticky.d.ts.map +1 -0
  59. package/dist/features/sections/types.d.ts +86 -0
  60. package/dist/features/sections/types.d.ts.map +1 -0
  61. package/dist/features/selection/index.d.ts +7 -0
  62. package/dist/features/selection/index.d.ts.map +1 -0
  63. package/dist/features/selection/plugin.d.ts +44 -0
  64. package/dist/features/selection/plugin.d.ts.map +1 -0
  65. package/dist/features/selection/state.d.ts +102 -0
  66. package/dist/features/selection/state.d.ts.map +1 -0
  67. package/dist/features/snapshots/index.d.ts +8 -0
  68. package/dist/features/snapshots/index.d.ts.map +1 -0
  69. package/dist/features/snapshots/plugin.d.ts +44 -0
  70. package/dist/features/snapshots/plugin.d.ts.map +1 -0
  71. package/dist/grid/index.js +1 -1
  72. package/dist/index.d.ts +17 -8
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1 -1
  75. package/dist/page/index.js +1 -0
  76. package/dist/react/index.js +1 -1
  77. package/dist/rendering/heights.d.ts +63 -0
  78. package/dist/rendering/heights.d.ts.map +1 -0
  79. package/dist/rendering/index.d.ts +9 -0
  80. package/dist/rendering/index.d.ts.map +1 -0
  81. package/dist/rendering/renderer.d.ts +103 -0
  82. package/dist/rendering/renderer.d.ts.map +1 -0
  83. package/dist/rendering/scale.d.ts +116 -0
  84. package/dist/rendering/scale.d.ts.map +1 -0
  85. package/dist/rendering/viewport.d.ts +139 -0
  86. package/dist/rendering/viewport.d.ts.map +1 -0
  87. package/dist/scale/index.js +1 -0
  88. package/dist/scrollbar/index.js +1 -0
  89. package/dist/sections/index.js +1 -0
  90. package/dist/selection/index.js +1 -1
  91. package/dist/svelte/index.js +1 -1
  92. package/dist/vue/index.js +1 -1
  93. package/package.json +1 -51
package/README.md CHANGED
@@ -1,49 +1,38 @@
1
1
  # vlist
2
2
 
3
- Lightweight, high-performance virtual list with zero dependencies.
3
+ Lightweight, high-performance virtual list with zero dependencies and optimal tree-shaking.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/%40floor%2Fvlist.svg)](https://www.npmjs.com/package/@floor/vlist)
6
6
  [![bundle size](https://img.shields.io/bundlephobia/minzip/@floor/vlist)](https://bundlephobia.com/package/@floor/vlist)
7
- [![tests](https://img.shields.io/badge/tests-1730%20passing-brightgreen)](https://github.com/floor/vlist)
7
+ [![tests](https://img.shields.io/badge/tests-1739%20passing-brightgreen)](https://github.com/floor/vlist)
8
8
  [![license](https://img.shields.io/npm/l/vlist.svg)](https://github.com/floor/vlist/blob/main/LICENSE)
9
9
 
10
10
  ## Features
11
11
 
12
12
  - 🪶 **Zero dependencies** - No external libraries required
13
- - ⚡ **Blazing fast** - Only renders visible items with element pooling
14
- - 🎯 **Simple API** - Easy to use with TypeScript support
15
- - 📐 **Grid layout** - 2D virtualized grid with configurable columns and gap
16
- - 📏 **Variable heights** - Fixed or per-item height via `(index) => number`
17
- - ↔️ **Horizontal scrolling** - Horizontal lists and carousels with `direction: 'horizontal'`
18
- - 📜 **Infinite scroll** - Built-in async adapter support
13
+ - ⚡ **Tiny bundle** - 8-12 KB gzipped (vs 20+ KB for traditional virtual lists)
14
+ - 🌲 **Perfect tree-shaking** - Pay only for features you use
15
+ - 🎯 **Builder API** - Explicit, composable plugin system
16
+ - 📐 **Grid layout** - 2D virtualized grid with configurable columns
17
+ - 📏 **Variable heights** - Fixed or per-item height calculation
18
+ - ↔️ **Horizontal scrolling** - Horizontal lists and carousels
19
+ - 📜 **Async loading** - Built-in lazy loading with adapters
19
20
  - ✅ **Selection** - Single and multiple selection modes
20
21
  - 📌 **Sticky headers** - Grouped lists with sticky section headers
21
- - 🪟 **Window scrolling** - Document-level scrolling with `scrollElement: window`
22
- - 🔄 **Wrap navigation** - Circular scrolling for wizards and carousels
22
+ - 🪟 **Page scrolling** - Document-level scrolling mode
23
+ - 🔄 **Wrap navigation** - Circular scrolling for wizards
24
+ - 💬 **Reverse mode** - Chat UI with auto-scroll and history loading
25
+ - ⚖️ **Scale to millions** - Handle 1M+ items with automatic compression
23
26
  - 🎨 **Customizable** - Beautiful, customizable styles
24
- - ♿ **Accessible** - WAI-ARIA listbox pattern, `aria-setsize`/`aria-posinset`, `aria-activedescendant`, live region, keyboard navigation
25
- - 🌊 **Smooth scrolling** - Animated `scrollToIndex` / `scrollToItem`
26
- - 💾 **Scroll save/restore** - `getScrollSnapshot()` / `restoreScroll()` for SPA navigation
27
- - 💬 **Reverse mode** - Chat UI support with auto-scroll, scroll-preserving prepend
28
- - 🔌 **Framework adapters** - Thin wrappers for React, Vue, and Svelte (<1 KB each)
29
- - 🌲 **Tree-shakeable** - Sub-module imports for smaller bundles
27
+ - ♿ **Accessible** - Full WAI-ARIA support and keyboard navigation
28
+ - 🔌 **Framework adapters** - React, Vue, and Svelte support
29
+ - 📱 **Mobile optimized** - Touch-friendly with momentum scrolling
30
30
 
31
31
  ## Sandbox & Documentation
32
32
 
33
- Interactive examples and documentation are available at **[vlist.dev](https://vlist.dev)**.
33
+ Interactive examples and documentation at **[vlist.dev](https://vlist.dev)**
34
34
 
35
- **14 examples** across 7 categories — many with multi-framework implementations (JavaScript, React, Svelte, Vue):
36
-
37
- | Category | Examples |
38
- |----------|----------|
39
- | **Getting Started** | [Basic](https://vlist.dev/sandbox/basic/) • [Controls](https://vlist.dev/sandbox/controls/) |
40
- | **Core (Lightweight)** | [Basic Core](https://vlist.dev/sandbox/core/basic/) — 7.8KB, 83% smaller |
41
- | **Grid Plugin** | [Photo Album](https://vlist.dev/sandbox/grid/photo-album/) • [File Browser](https://vlist.dev/sandbox/grid/file-browser/) |
42
- | **Data Plugin** | [Large List](https://vlist.dev/sandbox/data/large-list/) (100K–5M items) • [Velocity Loading](https://vlist.dev/sandbox/data/velocity-loading/) |
43
- | **Horizontal** | [Basic Horizontal](https://vlist.dev/sandbox/horizontal/basic/) — 10K card carousel |
44
- | **Groups Plugin** | [Sticky Headers](https://vlist.dev/sandbox/groups/sticky-headers/) — A–Z contact list |
45
- | **Other Plugins** | [Scroll Restore](https://vlist.dev/sandbox/scroll-restore/) • [Window Scroll](https://vlist.dev/sandbox/window-scroll/) |
46
- | **Advanced** | [Variable Heights](https://vlist.dev/sandbox/variable-heights/) • [Reverse Chat + Groups](https://vlist.dev/sandbox/reverse-chat/) • [Wizard Nav](https://vlist.dev/sandbox/wizard-nav/) |
35
+ **30+ examples** with multi-framework implementations (JavaScript, React, Vue, Svelte)
47
36
 
48
37
  ## Installation
49
38
 
@@ -51,821 +40,1008 @@ Interactive examples and documentation are available at **[vlist.dev](https://vl
51
40
  npm install @floor/vlist
52
41
  ```
53
42
 
54
- > **Note:** Currently published as `@floor/vlist` (scoped package). When the npm dispute for `vlist` is resolved, the package will migrate to the simpler `vlist` name.
55
-
56
- ### Sub-module Imports
57
-
58
- For smaller bundles, import only what you need:
59
-
60
- ```typescript
61
- import { createVList } from '@floor/vlist' // full library (70 KB / 23 KB gzip)
62
- import { createVList } from '@floor/vlist/core' // lightweight core (8 KB / 3 KB gzip)
63
- import { createVList } from '@floor/vlist/core-light' // ultra-minimal (5 KB / 2 KB gzip)
64
- import { createVList } from '@floor/vlist/builder' // declarative API (17 KB / 6 KB gzip)
65
- import { createGridLayout } from '@floor/vlist/grid' // grid layout utilities only
66
- import { createSparseStorage } from '@floor/vlist/data' // data utilities only
67
- import { getCompressionInfo } from '@floor/vlist/compression' // compression utilities only
68
- import { createSelectionState } from '@floor/vlist/selection' // selection utilities only
69
- import { createScrollController } from '@floor/vlist/scroll' // scroll utilities only
70
- import { createGroupLayout } from '@floor/vlist/groups' // group/sticky header utilities only
71
- ```
72
-
73
- | Import | Minified | Gzipped | Description |
74
- |--------|----------|---------|-------------|
75
- | `@floor/vlist` | 70 KB | 23 KB | All features (plugins + framework adapters) |
76
- | **`@floor/vlist/core`** | **8 KB** | **3 KB** | **Core virtual list — 88% smaller** |
77
- | **`@floor/vlist/core-light`** | **5 KB** | **2 KB** | **Ultra-minimal — 93% smaller** |
78
- | `@floor/vlist/builder` | 17 KB | 6 KB | Declarative API with chaining |
79
- | `@floor/vlist/data` | 12 KB | 5 KB | Sparse storage, placeholders, data manager |
80
- | `@floor/vlist/scroll` | 9 KB | 3 KB | Scroll controller + custom scrollbar |
81
- | `@floor/vlist/grid` | 10 KB | 4 KB | Grid layout + 2D renderer |
82
- | `@floor/vlist/groups` | 11 KB | 5 KB | Group layout + sticky headers |
83
- | `@floor/vlist/compression` | 8 KB | 3 KB | Large-list compression utilities |
84
- | `@floor/vlist/selection` | 6 KB | 2 KB | Selection state management |
85
-
86
- ### Framework Adapters
87
-
88
- Thin wrappers for React, Vue, and Svelte — each under 1 KB:
89
-
90
- ```typescript
91
- import { useVList } from '@floor/vlist/react' // React hook (62 KB / 21 KB gzip)
92
- import { useVList } from '@floor/vlist/vue' // Vue 3 composable (62 KB / 21 KB gzip)
93
- import { vlist } from '@floor/vlist/svelte' // Svelte action (62 KB / 20 KB gzip)
94
- ```
95
-
96
- | Import | Minified | Gzipped | Description |
97
- |--------|----------|---------|-------------|
98
- | `@floor/vlist/react` | 0.7 KB | 0.4 KB | `useVList` hook + `useVListEvent` |
99
- | `@floor/vlist/vue` | 0.5 KB | 0.4 KB | `useVList` composable + `useVListEvent` |
100
- | `@floor/vlist/svelte` | 0.3 KB | 0.2 KB | `vlist` action + `onVListEvent` |
101
-
102
- Adapters manage the vlist lifecycle (create on mount, destroy on unmount) and sync items reactively. See [Framework Adapters](#framework-adapters) for full examples.
43
+ > **Note:** Currently published as `@floor/vlist` (scoped package). When the npm dispute for `vlist` is resolved, the package will migrate to `vlist`.
103
44
 
104
45
  ## Quick Start
105
46
 
106
47
  ```typescript
107
- import { createVList } from '@floor/vlist';
108
- import '@floor/vlist/styles';
48
+ import { vlist } from 'vlist';
49
+ import 'vlist/styles';
109
50
 
110
- const list = createVList({
51
+ const list = vlist({
111
52
  container: '#my-list',
112
- ariaLabel: 'Contact list',
113
- item: {
114
- height: 48,
115
- template: (item) => `
116
- <div class="item-content">
117
- <img src="${item.avatar}" class="avatar" />
118
- <span>${item.name}</span>
119
- </div>
120
- `,
121
- },
122
53
  items: [
123
- { id: 1, name: 'Alice', avatar: '/avatars/alice.jpg' },
124
- { id: 2, name: 'Bob', avatar: '/avatars/bob.jpg' },
125
- // ... more items
54
+ { id: 1, name: 'Alice' },
55
+ { id: 2, name: 'Bob' },
56
+ { id: 3, name: 'Charlie' },
126
57
  ],
127
- });
128
- ```
129
-
130
- ### Lightweight Core (7.8 KB)
131
-
132
- If you don't need selection, groups, grid, compression, custom scrollbar, or async data adapters, use the lightweight core for an **83% smaller bundle**:
133
-
134
- ```typescript
135
- import { createVList } from '@floor/vlist/core';
136
- import '@floor/vlist/styles';
137
-
138
- const list = createVList({
139
- container: '#my-list',
140
58
  item: {
141
59
  height: 48,
142
60
  template: (item) => `<div>${item.name}</div>`,
143
61
  },
144
- items: myItems,
145
- });
62
+ }).build();
146
63
 
147
- // Same core API: setItems, appendItems, scrollToIndex, events, etc.
64
+ // API methods
65
+ list.scrollToIndex(10);
66
+ list.setItems(newItems);
148
67
  list.on('item:click', ({ item }) => console.log(item));
149
- list.scrollToIndex(50, { behavior: 'smooth' });
150
68
  ```
151
69
 
152
- 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.
70
+ **Bundle:** ~8 KB gzipped
153
71
 
154
- ## Configuration
72
+ ## Builder Pattern
155
73
 
156
- ```typescript
157
- interface VListConfig<T> {
158
- // Required
159
- container: HTMLElement | string; // Container element or selector
160
- item: {
161
- height: number | ((index: number) => number); // Fixed or variable height
162
- template: ItemTemplate<T>; // Render function for each item
163
- };
74
+ VList uses a composable builder pattern. Start with the base, add only the features you need:
164
75
 
165
- // Layout
166
- layout?: 'list' | 'grid'; // Layout mode (default: 'list')
167
- grid?: { // Grid config (required when layout: 'grid')
168
- columns: number; // Number of columns
169
- gap?: number; // Gap between items in px (default: 0)
170
- };
76
+ ```typescript
77
+ import { vlist, withGrid, withSections, withSelection } from 'vlist';
171
78
 
172
- // Data
173
- items?: T[]; // Static items array
174
- adapter?: VListAdapter<T>; // Async data adapter
79
+ const list = vlist({
80
+ container: '#app',
81
+ items: photos,
82
+ item: {
83
+ height: 200,
84
+ template: renderPhoto,
85
+ },
86
+ })
87
+ .use(withGrid({ columns: 4, gap: 16 }))
88
+ .use(withSections({
89
+ getGroupForIndex: (i) => photos[i].category,
90
+ headerHeight: 40,
91
+ headerTemplate: (cat) => `<h2>${cat}</h2>`,
92
+ }))
93
+ .use(withSelection({ mode: 'multiple' }))
94
+ .build();
95
+ ```
175
96
 
176
- // Scrolling
177
- overscan?: number; // Extra items to render (default: 3)
178
- scroll?: {
179
- wheel?: boolean; // Enable mouse wheel (default: true)
180
- wrap?: boolean; // Wrap around at boundaries (default: false)
181
- scrollbar?: 'native' | 'none' // Scrollbar mode (default: custom)
182
- | ScrollbarOptions; // or { autoHide, autoHideDelay, minThumbSize }
183
- element?: Window; // Window scrolling mode
184
- idleTimeout?: number; // Scroll idle detection in ms (default: 150)
185
- };
97
+ **Bundle:** ~12 KB gzipped (only includes used plugins)
186
98
 
187
- // Features
188
- selection?: SelectionConfig; // Selection configuration
189
- groups?: GroupsConfig; // Sticky headers / grouped lists
190
- loading?: LoadingConfig; // Velocity-based loading thresholds
99
+ ### Available Plugins
191
100
 
192
- // Chat UI
193
- reverse?: boolean; // Reverse mode (start at bottom, auto-scroll)
101
+ | Plugin | Cost | Description |
102
+ |--------|------|-------------|
103
+ | **Base** | 7.7 KB gzip | Core virtualization, no plugins |
104
+ | `withGrid()` | +4.0 KB | 2D grid layout |
105
+ | `withSections()` | +4.6 KB | Grouped lists with sticky/inline headers |
106
+ | `withAsync()` | +5.3 KB | Async data loading with adapters |
107
+ | `withSelection()` | +2.3 KB | Single/multiple item selection |
108
+ | `withScale()` | +2.2 KB | Handle 1M+ items with compression |
109
+ | `withScrollbar()` | +1.0 KB | Custom scrollbar UI |
110
+ | `withPage()` | +0.9 KB | Document-level scrolling |
111
+ | `withSnapshots()` | Included | Scroll save/restore |
194
112
 
195
- // Appearance
196
- classPrefix?: string; // CSS class prefix (default: 'vlist')
197
- ariaLabel?: string; // Accessible label for the listbox
198
- }
199
- ```
113
+ **Compare to monolithic:** Traditional virtual lists bundle everything = 20-23 KB gzipped minimum, regardless of usage.
200
114
 
201
115
  ## Examples
202
116
 
203
- ### Grid Layout
117
+ ### Simple List (No Plugins)
204
118
 
205
119
  ```typescript
206
- const grid = createVList({
207
- container: '#gallery',
208
- layout: 'grid',
209
- grid: {
210
- columns: 4,
211
- gap: 8, // 8px gap between columns AND rows
212
- },
120
+ import { vlist } from 'vlist';
121
+
122
+ const list = vlist({
123
+ container: '#list',
124
+ items: users,
213
125
  item: {
214
- height: 200,
215
- template: (item) => `
216
- <div class="card">
217
- <img src="${item.thumbnail}" />
218
- <span>${item.title}</span>
126
+ height: 64,
127
+ template: (user) => `
128
+ <div class="user">
129
+ <img src="${user.avatar}" />
130
+ <span>${user.name}</span>
219
131
  </div>
220
132
  `,
221
133
  },
222
- items: photos,
223
- });
134
+ }).build();
224
135
  ```
225
136
 
226
- 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.
137
+ **Bundle:** 8.2 KB gzipped
227
138
 
228
- ### Variable Heights
139
+ ### Grid Layout
229
140
 
230
141
  ```typescript
231
- const list = createVList({
232
- container: '#messages',
142
+ import { vlist, withGrid, withScrollbar } from 'vlist';
143
+
144
+ const gallery = vlist({
145
+ container: '#gallery',
146
+ items: photos,
233
147
  item: {
234
- height: (index) => messages[index].type === 'header' ? 32 : 64,
235
- template: (item) => `<div class="message">${item.text}</div>`,
148
+ height: 200,
149
+ template: (photo) => `
150
+ <div class="card">
151
+ <img src="${photo.url}" />
152
+ <span>${photo.title}</span>
153
+ </div>
154
+ `,
236
155
  },
237
- items: messages,
238
- });
156
+ })
157
+ .use(withGrid({ columns: 4, gap: 16 }))
158
+ .use(withScrollbar({ autoHide: true }))
159
+ .build();
239
160
  ```
240
161
 
241
- Variable heights use a prefix-sum array for O(1) offset lookups and O(log n) binary search for index-at-offset.
162
+ **Bundle:** 11.7 KB gzipped
242
163
 
243
- ### Sticky Headers (Contacts List)
164
+ Grid mode virtualizes by **rows** - only visible rows are in the DOM. Each item is positioned with `translate(x, y)` for GPU-accelerated rendering.
165
+
166
+ ### Sticky Headers (Contact List)
244
167
 
245
168
  ```typescript
246
- const list = createVList({
169
+ import { vlist, withSections } from 'vlist';
170
+
171
+ const contacts = vlist({
247
172
  container: '#contacts',
173
+ items: sortedContacts, // Must be pre-sorted by group
248
174
  item: {
249
175
  height: 56,
250
- template: (item) => `<div>${item.name}</div>`,
176
+ template: (contact) => `<div>${contact.name}</div>`,
251
177
  },
252
- items: contacts, // Must be pre-sorted by group
253
- groups: {
254
- getGroupForIndex: (index) => contacts[index].lastName[0],
178
+ })
179
+ .use(withSections({
180
+ getGroupForIndex: (i) => contacts[i].lastName[0].toUpperCase(),
255
181
  headerHeight: 36,
256
- headerTemplate: (group) => `<div class="section-header">${group}</div>`,
257
- sticky: true, // Headers stick to the top (default: true)
258
- },
259
- });
182
+ headerTemplate: (letter) => `<div class="header">${letter}</div>`,
183
+ sticky: true, // Headers stick to top (Telegram style)
184
+ }))
185
+ .build();
260
186
  ```
261
187
 
262
- ### Chat with Date Headers (Reverse + Groups)
188
+ **Bundle:** 12.3 KB gzipped
189
+
190
+ Set `sticky: false` for inline headers (iMessage/WhatsApp style).
191
+
192
+ ### Chat UI (Reverse + Sections)
263
193
 
264
194
  ```typescript
265
- const chat = createVList({
195
+ import { vlist, withSections } from 'vlist';
196
+
197
+ const chat = vlist({
266
198
  container: '#messages',
267
- reverse: true,
199
+ reverse: true, // Start at bottom, newest messages visible
200
+ items: messages, // Chronological order (oldest first)
268
201
  item: {
269
- height: (index) => messages[index].height || 60,
202
+ height: (i) => messages[i].height || 60,
270
203
  template: (msg) => `<div class="message">${msg.text}</div>`,
271
204
  },
272
- items: messages, // Chronological order (oldest first)
273
- groups: {
205
+ })
206
+ .use(withSections({
274
207
  getGroupForIndex: (i) => {
275
208
  const date = new Date(messages[i].timestamp);
276
- return date.toLocaleDateString(); // "Dec 12", "Dec 14", etc.
209
+ return date.toLocaleDateString(); // "Jan 15", "Jan 16", etc.
277
210
  },
278
- headerHeight: 28,
211
+ headerHeight: 32,
279
212
  headerTemplate: (date) => `<div class="date-header">${date}</div>`,
280
- sticky: false, // inline headers (iMessage style) or true (Telegram style)
281
- },
282
- });
213
+ sticky: false, // Inline date headers (iMessage style)
214
+ }))
215
+ .build();
216
+
217
+ // New messages - auto-scrolls to bottom
218
+ chat.appendItems([newMessage]);
219
+
220
+ // Load history - preserves scroll position
221
+ chat.prependItems(olderMessages);
283
222
  ```
284
223
 
285
- Combines reverse mode with groups for iMessage/Telegram-style chat UIs. Use `sticky: false` for inline date headers (iMessage, WhatsApp) or `sticky: true` for headers that stick at top while scrolling (Telegram). As you scroll up through history, date headers appear automatically.
224
+ **Bundle:** 11.9 KB gzipped
286
225
 
287
- ### Window Scrolling
226
+ Perfect for iMessage, WhatsApp, Telegram-style chat interfaces.
227
+
228
+ ### Large Datasets (1M+ Items)
288
229
 
289
230
  ```typescript
290
- const list = createVList({
291
- container: '#my-list',
292
- scroll: { element: window }, // Use the browser's native scrollbar
231
+ import { vlist, withScale, withScrollbar } from 'vlist';
232
+
233
+ const bigList = vlist({
234
+ container: '#big-list',
235
+ items: generateItems(5_000_000),
293
236
  item: {
294
237
  height: 48,
295
- template: (item) => `<div>${item.name}</div>`,
238
+ template: (item) => `<div>#${item.id}: ${item.name}</div>`,
296
239
  },
297
- items: myItems,
298
- });
240
+ })
241
+ .use(withScale()) // Auto-activates when height > 16.7M pixels
242
+ .use(withScrollbar({ autoHide: true }))
243
+ .build();
299
244
  ```
300
245
 
301
- ### Wizard / Carousel (Wrap Navigation)
246
+ **Bundle:** 9.9 KB gzipped
247
+
248
+ The scale plugin automatically compresses scroll space when total height exceeds browser limits (~16.7M pixels), enabling smooth scrolling through millions of items.
249
+
250
+ ### Async Loading with Pagination
302
251
 
303
252
  ```typescript
304
- const wizard = createVList({
305
- container: '#wizard',
306
- scroll: { wheel: false, scrollbar: 'none', wrap: true },
253
+ import { vlist, withAsync } from 'vlist';
254
+
255
+ const list = vlist({
256
+ container: '#list',
307
257
  item: {
308
- height: 400,
309
- template: (step) => `<div class="step">${step.content}</div>`,
258
+ height: 64,
259
+ template: (item) => {
260
+ if (!item) return `<div class="loading">Loading...</div>`;
261
+ return `<div>${item.name}</div>`;
262
+ },
310
263
  },
311
- items: steps,
312
- });
264
+ })
265
+ .use(withAsync({
266
+ adapter: {
267
+ read: async ({ offset, limit }) => {
268
+ const response = await fetch(`/api/users?offset=${offset}&limit=${limit}`);
269
+ const data = await response.json();
270
+ return {
271
+ items: data.items,
272
+ total: data.total,
273
+ hasMore: data.hasMore,
274
+ };
275
+ },
276
+ },
277
+ loading: {
278
+ cancelThreshold: 15, // Cancel loads when scrolling fast (pixels/ms)
279
+ },
280
+ }))
281
+ .build();
282
+ ```
313
283
 
314
- let current = 0;
284
+ **Bundle:** 13.5 KB gzipped
315
285
 
316
- // No boundary checks needed wrap handles it
317
- btnNext.addEventListener('click', () => {
318
- current++;
319
- wizard.scrollToIndex(current, { align: 'start', behavior: 'smooth' });
320
- });
286
+ The async plugin shows placeholders for unloaded items and fetches data as you scroll. Velocity-aware loading cancels requests when scrolling fast.
321
287
 
322
- btnPrev.addEventListener('click', () => {
323
- current--;
324
- wizard.scrollToIndex(current, { align: 'start', behavior: 'smooth' });
325
- });
326
- ```
327
-
328
- ### Reverse Mode (Chat UI)
288
+ ### Page-Level Scrolling
329
289
 
330
290
  ```typescript
331
- const chat = createVList({
332
- container: '#messages',
333
- reverse: true,
291
+ import { vlist, withPage } from 'vlist';
292
+
293
+ const list = vlist({
294
+ container: '#list',
295
+ items: articles,
334
296
  item: {
335
- height: (index) => messages[index].type === 'image' ? 200 : 60,
336
- template: (msg) => `
337
- <div class="bubble bubble--${msg.sender}">
338
- <span class="sender">${msg.sender}</span>
339
- <p>${msg.text}</p>
340
- </div>
341
- `,
297
+ height: 200,
298
+ template: (article) => `<article>...</article>`,
342
299
  },
343
- items: messages, // Chronological order (oldest first)
344
- });
345
-
346
- // New message arrives — auto-scrolls to bottom if user was at bottom
347
- chat.appendItems([newMessage]);
348
-
349
- // Load older messages — scroll position preserved (no jump)
350
- chat.prependItems(olderMessages);
300
+ })
301
+ .use(withPage()) // Uses document scroll instead of container
302
+ .build();
351
303
  ```
352
304
 
353
- 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. **Works with `groups`** for date headers (both inline and sticky). Cannot be combined with `grid`.
305
+ **Bundle:** 8.6 KB gzipped
354
306
 
355
- ### With Selection
307
+ Perfect for blog posts, infinite scroll feeds, and full-page lists.
308
+
309
+ ### Horizontal Carousel
356
310
 
357
311
  ```typescript
358
- const list = createVList({
359
- container: '#my-list',
312
+ import { vlist } from 'vlist';
313
+
314
+ const carousel = vlist({
315
+ container: '#carousel',
316
+ direction: 'horizontal',
317
+ items: cards,
360
318
  item: {
361
- height: 56,
362
- template: (item, index, { selected }) => `
363
- <div class="item-content ${selected ? 'selected' : ''}">
364
- <span>${item.name}</span>
365
- ${selected ? '✓' : ''}
366
- </div>
367
- `,
319
+ width: 300, // Required for horizontal
320
+ height: 400, // Optional (can use CSS)
321
+ template: (card) => `<div class="card">...</div>`,
368
322
  },
369
- items: users,
370
- selection: {
371
- mode: 'multiple', // 'none' | 'single' | 'multiple'
372
- initial: [1, 2], // Initially selected IDs
323
+ scroll: {
324
+ wrap: true, // Circular scrolling
373
325
  },
374
- });
326
+ }).build();
327
+ ```
375
328
 
376
- // Listen for selection changes
377
- list.on('selection:change', ({ selected, items }) => {
378
- console.log('Selected:', selected);
379
- });
329
+ **Bundle:** 8.6 KB gzipped
330
+
331
+ ### Selection & Navigation
380
332
 
381
- // Programmatic selection
382
- list.select(5);
383
- list.deselect(1);
384
- list.selectAll();
333
+ ```typescript
334
+ import { vlist, withSelection } from 'vlist';
335
+
336
+ const list = vlist({
337
+ container: '#list',
338
+ items: users,
339
+ item: {
340
+ height: 48,
341
+ template: (user, index, { selected }) => {
342
+ const cls = selected ? 'item--selected' : '';
343
+ return `<div class="${cls}">${user.name}</div>`;
344
+ },
345
+ },
346
+ })
347
+ .use(withSelection({
348
+ mode: 'multiple',
349
+ initial: [1, 5, 10], // Pre-select items
350
+ }))
351
+ .build();
352
+
353
+ // Selection API
354
+ list.selectItem(5);
355
+ list.deselectItem(5);
356
+ list.toggleSelection(5);
357
+ list.getSelectedIds(); // [1, 5, 10]
385
358
  list.clearSelection();
386
359
  ```
387
360
 
388
- ### With Infinite Scroll
361
+ **Bundle:** 10.0 KB gzipped
362
+
363
+ Supports `mode: 'single'` or `'multiple'` with keyboard navigation (Arrow keys, Home, End, Space, Enter).
364
+
365
+ ### Variable Heights (Chat Messages)
389
366
 
390
367
  ```typescript
391
- const list = createVList({
392
- container: '#my-list',
368
+ import { vlist } from 'vlist';
369
+
370
+ const list = vlist({
371
+ container: '#messages',
372
+ items: messages,
393
373
  item: {
394
- height: 64,
395
- template: (item) => `<div>${item.title}</div>`,
396
- },
397
- adapter: {
398
- read: async ({ offset, limit }) => {
399
- const response = await fetch(
400
- `/api/items?offset=${offset}&limit=${limit}`
401
- );
402
- const data = await response.json();
403
-
404
- return {
405
- items: data.items,
406
- total: data.total,
407
- hasMore: data.hasMore,
408
- };
374
+ height: (index) => {
375
+ // Heights computed from actual DOM measurements
376
+ return messages[index].measuredHeight || 60;
409
377
  },
378
+ template: (msg) => `
379
+ <div class="message">
380
+ <div class="author">${msg.user}</div>
381
+ <div class="text">${msg.text}</div>
382
+ </div>
383
+ `,
410
384
  },
411
- });
385
+ }).build();
386
+ ```
412
387
 
413
- // Listen for loading events
414
- list.on('load:start', ({ offset, limit }) => {
415
- console.log('Loading...', offset, limit);
416
- });
388
+ **Bundle:** 10.9 KB gzipped
417
389
 
418
- list.on('load:end', ({ items, total }) => {
419
- console.log('Loaded', items.length, 'of', total);
420
- });
390
+ Variable heights use a prefix-sum array for O(1) offset lookups and O(log n) binary search.
391
+
392
+ ## API Reference
393
+
394
+ ### Core Methods
395
+
396
+ ```typescript
397
+ const list = vlist(config).use(...plugins).build();
398
+
399
+ // Data manipulation
400
+ list.setItems(items: T[]): void
401
+ list.appendItems(items: T[]): void
402
+ list.prependItems(items: T[]): void
403
+ list.updateItem(id: string | number, item: Partial<T>): boolean
404
+ list.removeItem(id: string | number): boolean
405
+
406
+ // Navigation
407
+ list.scrollToIndex(index: number, align?: 'start' | 'center' | 'end'): void
408
+ list.scrollToIndex(index: number, options?: {
409
+ align?: 'start' | 'center' | 'end',
410
+ behavior?: 'auto' | 'smooth',
411
+ duration?: number
412
+ }): void
413
+ list.scrollToItem(id: string | number, align?: string): void
414
+
415
+ // State
416
+ list.getScrollPosition(): number
417
+ list.getVisibleRange(): { start: number, end: number }
418
+ list.getScrollSnapshot(): ScrollSnapshot
419
+ list.restoreScroll(snapshot: ScrollSnapshot): void
420
+
421
+ // Events
422
+ list.on(event: string, handler: Function): Unsubscribe
423
+ list.off(event: string, handler: Function): void
424
+
425
+ // Lifecycle
426
+ list.destroy(): void
427
+
428
+ // Properties
429
+ list.element: HTMLElement
430
+ list.items: readonly T[]
431
+ list.total: number
421
432
  ```
422
433
 
423
- ### Scroll Save/Restore
434
+ ### Selection Methods (with `withSelection()`)
424
435
 
425
436
  ```typescript
426
- const list = createVList({
427
- container: '#my-list',
428
- item: {
429
- height: 64,
430
- template: (item) => `<div>${item.name}</div>`,
431
- },
432
- items: myItems,
433
- selection: { mode: 'multiple' },
434
- });
437
+ list.selectItem(id: string | number): void
438
+ list.deselectItem(id: string | number): void
439
+ list.toggleSelection(id: string | number): void
440
+ list.selectAll(): void
441
+ list.clearSelection(): void
442
+ list.getSelectedIds(): Array<string | number>
443
+ list.getSelectedItems(): T[]
444
+ list.setSelectionMode(mode: 'none' | 'single' | 'multiple'): void
445
+ ```
435
446
 
436
- // Save e.g. before navigating away
437
- const snapshot = list.getScrollSnapshot();
438
- // { index: 523, offsetInItem: 12, selectedIds: [3, 7, 42] }
439
- sessionStorage.setItem('list-scroll', JSON.stringify(snapshot));
447
+ ### Grid Methods (with `withGrid()`)
440
448
 
441
- // Restore — e.g. after navigating back and recreating the list
442
- const saved = JSON.parse(sessionStorage.getItem('list-scroll'));
443
- list.restoreScroll(saved);
444
- // Scroll position AND selection are perfectly restored
449
+ ```typescript
450
+ list.updateGrid(config: { columns?: number, gap?: number }): void
445
451
  ```
446
452
 
447
- ### Framework Adapters
453
+ ### Events
448
454
 
449
- 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.
455
+ ```typescript
456
+ list.on('scroll', ({ scrollTop, direction }) => { })
457
+ list.on('range:change', ({ range }) => { })
458
+ list.on('item:click', ({ item, index, event }) => { })
459
+ list.on('item:dblclick', ({ item, index, event }) => { })
460
+ list.on('selection:change', ({ selectedIds, selectedItems }) => { })
461
+ list.on('load:start', ({ offset, limit }) => { })
462
+ list.on('load:end', ({ items, offset, total }) => { })
463
+ list.on('load:error', ({ error, offset, limit }) => { })
464
+ list.on('velocity:change', ({ velocity, reliable }) => { })
465
+ ```
450
466
 
451
- #### React
467
+ ## Configuration
452
468
 
453
- ```tsx
454
- import { useVList, useVListEvent } from 'vlist/react';
469
+ ### Base Configuration
455
470
 
456
- function UserList({ users }) {
457
- const { containerRef, instanceRef } = useVList({
458
- item: {
459
- height: 48,
460
- template: (user) => `<div class="user">${user.name}</div>`,
461
- },
462
- items: users,
463
- selection: { mode: 'single' },
464
- });
471
+ ```typescript
472
+ interface VListConfig<T> {
473
+ // Required
474
+ container: HTMLElement | string;
475
+ item: {
476
+ height?: number | ((index: number) => number); // Required for vertical
477
+ width?: number | ((index: number) => number); // Required for horizontal
478
+ template: (item: T, index: number, state: ItemState) => string | HTMLElement;
479
+ };
465
480
 
466
- // Optional: subscribe to events with automatic cleanup
467
- useVListEvent(instanceRef, 'selection:change', ({ selected }) => {
468
- console.log('Selected:', selected);
469
- });
481
+ // Optional
482
+ items?: T[]; // Initial items
483
+ overscan?: number; // Extra items to render (default: 3)
484
+ direction?: 'vertical' | 'horizontal'; // Default: 'vertical'
485
+ reverse?: boolean; // Reverse mode for chat (default: false)
486
+ classPrefix?: string; // CSS class prefix (default: 'vlist')
487
+ ariaLabel?: string; // Accessible label
470
488
 
471
- return (
472
- <div
473
- ref={containerRef}
474
- style={{ height: 400 }}
475
- onClick={() => instanceRef.current?.scrollToIndex(0)}
476
- />
477
- );
489
+ scroll?: {
490
+ wheel?: boolean; // Enable mouse wheel (default: true)
491
+ wrap?: boolean; // Circular scrolling (default: false)
492
+ scrollbar?: 'none'; // Hide scrollbar
493
+ idleTimeout?: number; // Scroll idle detection (default: 150ms)
494
+ };
478
495
  }
479
496
  ```
480
497
 
481
- `useVList` returns:
482
- - `containerRef` — attach to your container `<div>`
483
- - `instanceRef` — ref to the `VList` instance (populated after mount)
484
- - `getInstance()` — stable helper to access the instance
485
-
486
- Items auto-sync when `config.items` changes by reference.
498
+ ### Plugin: `withGrid(config)`
487
499
 
488
- #### Vue
500
+ 2D grid layout with virtualized rows.
489
501
 
490
- ```vue
491
- <template>
492
- <div ref="containerRef" style="height: 400px" />
493
- </template>
494
-
495
- <script setup lang="ts">
496
- import { useVList, useVListEvent } from 'vlist/vue';
497
- import { ref } from 'vue';
502
+ ```typescript
503
+ interface GridConfig {
504
+ columns: number; // Number of columns (required)
505
+ gap?: number; // Gap between items in pixels (default: 0)
506
+ }
507
+ ```
498
508
 
499
- const users = ref([
500
- { id: 1, name: 'Alice' },
501
- { id: 2, name: 'Bob' },
502
- ]);
509
+ **Example:**
510
+ ```typescript
511
+ .use(withGrid({ columns: 4, gap: 16 }))
512
+ ```
503
513
 
504
- const { containerRef, instance } = useVList({
505
- item: {
506
- height: 48,
507
- template: (user) => `<div class="user">${user.name}</div>`,
508
- },
509
- items: users.value,
510
- });
514
+ ### Plugin: `withSections(config)`
511
515
 
512
- // Optional: subscribe to events with automatic cleanup
513
- useVListEvent(instance, 'selection:change', ({ selected }) => {
514
- console.log('Selected:', selected);
515
- });
516
+ Grouped lists with sticky or inline headers.
516
517
 
517
- function jumpToTop() {
518
- instance.value?.scrollToIndex(0);
518
+ ```typescript
519
+ interface SectionsConfig {
520
+ getGroupForIndex: (index: number) => string;
521
+ headerHeight: number | ((group: string, groupIndex: number) => number);
522
+ headerTemplate: (group: string, groupIndex: number) => string | HTMLElement;
523
+ sticky?: boolean; // Default: true (Telegram style), false = inline (iMessage style)
519
524
  }
520
- </script>
521
525
  ```
522
526
 
523
- `useVList` accepts a plain config or a reactive `Ref<Config>`. When using a ref, items are watched and synced automatically.
524
-
525
- #### Svelte
527
+ **Example:**
528
+ ```typescript
529
+ .use(withSections({
530
+ getGroupForIndex: (i) => items[i].category,
531
+ headerHeight: 40,
532
+ headerTemplate: (cat) => `<h2>${cat}</h2>`,
533
+ sticky: true,
534
+ }))
535
+ ```
526
536
 
527
- ```svelte
528
- <script>
529
- import { vlist, onVListEvent } from 'vlist/svelte';
537
+ **Important:** Items must be pre-sorted by group!
530
538
 
531
- let instance;
532
- let unsubs = [];
539
+ ### Plugin: `withSelection(config)`
533
540
 
534
- const options = {
535
- config: {
536
- item: {
537
- height: 48,
538
- template: (user) => `<div class="user">${user.name}</div>`,
539
- },
540
- items: users,
541
- selection: { mode: 'single' },
542
- },
543
- onInstance: (inst) => {
544
- instance = inst;
545
- unsubs.push(
546
- onVListEvent(inst, 'selection:change', ({ selected }) => {
547
- console.log('Selected:', selected);
548
- })
549
- );
550
- },
551
- };
541
+ Single or multiple item selection with keyboard navigation.
552
542
 
553
- import { onDestroy } from 'svelte';
554
- onDestroy(() => unsubs.forEach(fn => fn()));
555
- </script>
543
+ ```typescript
544
+ interface SelectionConfig {
545
+ mode: 'single' | 'multiple';
546
+ initial?: Array<string | number>; // Pre-selected item IDs
547
+ }
548
+ ```
556
549
 
557
- <div use:vlist={options} style="height: 400px" />
558
- <button on:click={() => instance?.scrollToIndex(0)}>Jump to top</button>
550
+ **Example:**
551
+ ```typescript
552
+ .use(withSelection({
553
+ mode: 'multiple',
554
+ initial: [1, 5, 10],
555
+ }))
559
556
  ```
560
557
 
561
- 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.
558
+ ### Plugin: `withAsync(config)`
562
559
 
563
- ### With Custom Template
560
+ Asynchronous data loading with lazy loading and placeholders.
564
561
 
565
562
  ```typescript
566
- const list = createVList({
567
- container: '#my-list',
568
- item: {
569
- height: 72,
570
- template: (item, index, { selected, focused }) => {
571
- // Return an HTMLElement for more control
572
- const el = document.createElement('div');
573
- el.className = 'item-content';
574
- el.innerHTML = `
575
- <img src="${item.avatar}" class="avatar avatar--large" />
576
- <div class="item-details">
577
- <div class="item-name">${item.name}</div>
578
- <div class="item-email">${item.email}</div>
579
- </div>
580
- <div class="item-role">${item.role}</div>
581
- `;
582
- return el;
563
+ interface AsyncConfig {
564
+ adapter: {
565
+ read: (params: { offset: number, limit: number }) => Promise<{
566
+ items: T[];
567
+ total?: number;
568
+ hasMore?: boolean;
569
+ }>;
570
+ };
571
+ loading?: {
572
+ cancelThreshold?: number; // Cancel loads when scrolling fast (px/ms)
573
+ };
574
+ }
575
+ ```
576
+
577
+ **Example:**
578
+ ```typescript
579
+ .use(withAsync({
580
+ adapter: {
581
+ read: async ({ offset, limit }) => {
582
+ const response = await fetch(`/api?offset=${offset}&limit=${limit}`);
583
+ return response.json();
583
584
  },
584
585
  },
585
- items: users,
586
- });
586
+ loading: {
587
+ cancelThreshold: 15, // Skip loads when scrolling > 15px/ms
588
+ },
589
+ }))
587
590
  ```
588
591
 
589
- ## API Reference
592
+ ### Plugin: `withScale()`
590
593
 
591
- ### Methods
594
+ Automatically handles lists with 1M+ items by compressing scroll space when total height exceeds browser limits (~16.7M pixels).
592
595
 
593
- #### Data Management
596
+ ```typescript
597
+ .use(withScale()) // No config needed - auto-activates
598
+ ```
594
599
 
600
+ **Example:**
595
601
  ```typescript
596
- list.setItems(items: T[]) // Replace all items
597
- list.appendItems(items: T[]) // Add items to end
598
- list.prependItems(items: T[]) // Add items to start
599
- list.updateItem(id, updates) // Update item by ID
600
- list.removeItem(id) // Remove item by ID
601
- list.reload() // Reload from adapter
602
+ const bigList = vlist({
603
+ container: '#list',
604
+ items: generateItems(5_000_000),
605
+ item: { height: 48, template: renderItem },
606
+ })
607
+ .use(withScale())
608
+ .build();
602
609
  ```
603
610
 
604
- #### Scrolling
611
+ ### Plugin: `withScrollbar(config)`
612
+
613
+ Custom scrollbar with auto-hide and smooth dragging.
605
614
 
606
615
  ```typescript
607
- list.scrollToIndex(index, align?) // Scroll to index ('start' | 'center' | 'end')
608
- list.scrollToIndex(index, options?) // Scroll with options (smooth scrolling)
609
- list.scrollToItem(id, align?) // Scroll to item by ID
610
- list.scrollToItem(id, options?) // Scroll to item with options
611
- list.cancelScroll() // Cancel in-progress smooth scroll
612
- list.getScrollPosition() // Get current scroll position
613
- list.getScrollSnapshot() // Get snapshot for save/restore
614
- list.restoreScroll(snapshot) // Restore position (and selection) from snapshot
616
+ interface ScrollbarConfig {
617
+ autoHide?: boolean; // Hide when not scrolling (default: false)
618
+ autoHideDelay?: number; // Hide delay in ms (default: 1000)
619
+ minThumbSize?: number; // Minimum thumb size in px (default: 20)
620
+ }
621
+ ```
615
622
 
616
- // ScrollToOptions: { align?, behavior?: 'auto' | 'smooth', duration? }
617
- // Example: list.scrollToIndex(500, { align: 'center', behavior: 'smooth' })
618
- // ScrollSnapshot: { index, offsetInItem, selectedIds? } — JSON-serializable
623
+ **Example:**
624
+ ```typescript
625
+ .use(withScrollbar({
626
+ autoHide: true,
627
+ autoHideDelay: 1500,
628
+ }))
619
629
  ```
620
630
 
621
- #### Selection
631
+ ### Plugin: `withPage()`
632
+
633
+ Use document-level scrolling instead of container scrolling.
622
634
 
623
635
  ```typescript
624
- list.select(...ids) // Select items
625
- list.deselect(...ids) // Deselect items
626
- list.toggleSelect(id) // Toggle selection
627
- list.selectAll() // Select all
628
- list.clearSelection() // Clear selection
629
- list.getSelected() // Get selected IDs
630
- list.getSelectedItems() // Get selected items
636
+ .use(withPage()) // No config needed
631
637
  ```
632
638
 
633
- #### Events
634
-
639
+ **Example:**
635
640
  ```typescript
636
- list.on(event, handler) // Subscribe to event
637
- list.off(event, handler) // Unsubscribe from event
641
+ const list = vlist({
642
+ container: '#list',
643
+ items: articles,
644
+ item: { height: 300, template: renderArticle },
645
+ })
646
+ .use(withPage())
647
+ .build();
638
648
  ```
639
649
 
640
- #### Lifecycle
650
+ Perfect for blog posts, infinite scroll feeds, and full-page lists.
651
+
652
+ ### Plugin: `withSnapshots()`
653
+
654
+ Scroll position save/restore for SPA navigation.
641
655
 
642
656
  ```typescript
643
- list.destroy() // Cleanup and remove
657
+ // Included in base - no need to import
658
+ const snapshot = list.getScrollSnapshot();
659
+ list.restoreScroll(snapshot);
644
660
  ```
645
661
 
646
- ### Properties
647
-
662
+ **Example:**
648
663
  ```typescript
649
- list.element // Root DOM element
650
- list.items // Current items (readonly)
651
- list.total // Total item count
664
+ // Save on navigation away
665
+ const snapshot = list.getScrollSnapshot();
666
+ sessionStorage.setItem('scroll', JSON.stringify(snapshot));
667
+
668
+ // Restore on return
669
+ const snapshot = JSON.parse(sessionStorage.getItem('scroll'));
670
+ list.restoreScroll(snapshot);
652
671
  ```
653
672
 
654
- ### Events
673
+ ## Advanced Usage
674
+
675
+ ### Variable Heights with DOM Measurement
655
676
 
656
- | Event | Payload | Description |
657
- |-------|---------|-------------|
658
- | `item:click` | `{ item, index, event }` | Item was clicked |
659
- | `selection:change` | `{ selected, items }` | Selection changed |
660
- | `scroll` | `{ scrollTop, direction }` | Scroll position changed |
661
- | `range:change` | `{ range }` | Visible range changed |
662
- | `resize` | `{ height, width }` | Container was resized |
663
- | `load:start` | `{ offset, limit }` | Data loading started |
664
- | `load:end` | `{ items, total }` | Data loading completed |
665
- | `error` | `{ error, context }` | Error occurred |
677
+ ```typescript
678
+ import { vlist } from 'vlist';
666
679
 
667
- ## Keyboard Navigation & Accessibility
680
+ // Measure items before creating list
681
+ const measuringDiv = document.createElement('div');
682
+ measuringDiv.style.cssText = 'position:absolute;visibility:hidden;width:400px';
683
+ document.body.appendChild(measuringDiv);
668
684
 
669
- vlist implements the [WAI-ARIA Listbox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/listbox/) for full screen reader and keyboard support.
685
+ items.forEach(item => {
686
+ measuringDiv.innerHTML = renderItem(item);
687
+ item.measuredHeight = measuringDiv.offsetHeight;
688
+ });
670
689
 
671
- ### Keyboard Shortcuts
690
+ document.body.removeChild(measuringDiv);
672
691
 
673
- When selection is enabled, the list supports full keyboard navigation:
692
+ // Use measured heights
693
+ const list = vlist({
694
+ container: '#list',
695
+ items,
696
+ item: {
697
+ height: (i) => items[i].measuredHeight,
698
+ template: renderItem,
699
+ },
700
+ }).build();
701
+ ```
674
702
 
675
- | Key | Action |
676
- |-----|--------|
677
- | `↑` / `↓` | Move focus up/down |
678
- | `Home` | Move focus to first item |
679
- | `End` | Move focus to last item |
680
- | `Space` / `Enter` | Toggle selection on focused item |
681
- | `Tab` | Move focus into / out of the list |
703
+ ### Dynamic Grid Columns
682
704
 
683
- ### ARIA Attributes
705
+ ```typescript
706
+ import { vlist, withGrid } from 'vlist';
684
707
 
685
- | Attribute | Element | Purpose |
686
- |-----------|---------|---------|
687
- | `role="listbox"` | Root | Identifies the widget as a list of selectable items |
688
- | `role="option"` | Each item | Identifies each item as a selectable option |
689
- | `aria-setsize` | Each item | Total item count — screen readers announce "item 5 of 10,000" |
690
- | `aria-posinset` | Each item | 1-based position within the list |
691
- | `aria-activedescendant` | Root | Points to the focused item's ID for screen reader tracking |
692
- | `aria-selected` | Each item | Reflects selection state |
693
- | `aria-busy` | Root | Present during async data loading |
694
- | `aria-label` | Root | Set via `ariaLabel` config option |
708
+ let currentColumns = 4;
695
709
 
696
- A visually-hidden **live region** (`aria-live="polite"`) announces selection changes (e.g., "3 items selected").
710
+ const list = vlist({
711
+ container: '#gallery',
712
+ items: photos,
713
+ item: {
714
+ height: 200,
715
+ template: renderPhoto,
716
+ },
717
+ })
718
+ .use(withGrid({ columns: currentColumns, gap: 16 }))
719
+ .build();
720
+
721
+ // Update grid on resize
722
+ window.addEventListener('resize', () => {
723
+ const width = container.clientWidth;
724
+ const newColumns = width > 1200 ? 6 : width > 800 ? 4 : 2;
725
+ if (newColumns !== currentColumns) {
726
+ currentColumns = newColumns;
727
+ list.updateGrid({ columns: newColumns });
728
+ }
729
+ });
730
+ ```
697
731
 
698
- Each item receives a unique `id` (`vlist-{instance}-item-{index}`) safe for multiple lists per page.
732
+ ### Combining Multiple Plugins
699
733
 
700
- > 📖 Full documentation: [docs/accessibility.md](docs/accessibility.md)
734
+ ```typescript
735
+ import {
736
+ vlist,
737
+ withGrid,
738
+ withSections,
739
+ withSelection,
740
+ withAsync,
741
+ withScrollbar
742
+ } from 'vlist';
743
+
744
+ const list = vlist({
745
+ container: '#gallery',
746
+ item: {
747
+ height: 200,
748
+ template: renderPhoto,
749
+ },
750
+ })
751
+ .use(withAsync({ adapter: photoAdapter }))
752
+ .use(withGrid({ columns: 4, gap: 16 }))
753
+ .use(withSections({
754
+ getGroupForIndex: (i) => items[i]?.category || 'Loading...',
755
+ headerHeight: 48,
756
+ headerTemplate: (cat) => `<h2>${cat}</h2>`,
757
+ }))
758
+ .use(withSelection({ mode: 'multiple' }))
759
+ .use(withScrollbar({ autoHide: true }))
760
+ .build();
761
+ ```
701
762
 
702
- ## Styling
763
+ **Bundle:** ~15 KB gzipped (includes only used plugins)
703
764
 
704
- ### Default Styles
765
+ ## Framework Adapters
705
766
 
706
- Import the default styles:
767
+ ### React
707
768
 
708
769
  ```typescript
709
- import 'vlist/styles';
770
+ import { vlist, withSelection } from 'vlist';
771
+ import { useEffect, useRef } from 'react';
772
+
773
+ function MyList({ items }) {
774
+ const containerRef = useRef(null);
775
+ const listRef = useRef(null);
776
+
777
+ useEffect(() => {
778
+ if (!containerRef.current) return;
779
+
780
+ listRef.current = vlist({
781
+ container: containerRef.current,
782
+ items,
783
+ item: { height: 48, template: renderItem },
784
+ })
785
+ .use(withSelection({ mode: 'single' }))
786
+ .build();
787
+
788
+ return () => listRef.current?.destroy();
789
+ }, []);
790
+
791
+ useEffect(() => {
792
+ listRef.current?.setItems(items);
793
+ }, [items]);
794
+
795
+ return <div ref={containerRef} />;
796
+ }
710
797
  ```
711
798
 
712
- Optional extras (variants, loading states, animations):
799
+ ### Vue 3
713
800
 
714
801
  ```typescript
715
- import 'vlist/styles/extras';
802
+ import { vlist, withSelection } from 'vlist';
803
+ import { ref, onMounted, onUnmounted, watch } from 'vue';
804
+
805
+ export default {
806
+ setup() {
807
+ const container = ref(null);
808
+ const list = ref(null);
809
+
810
+ onMounted(() => {
811
+ list.value = vlist({
812
+ container: container.value,
813
+ items: items.value,
814
+ item: { height: 48, template: renderItem },
815
+ })
816
+ .use(withSelection({ mode: 'single' }))
817
+ .build();
818
+ });
819
+
820
+ watch(items, (newItems) => {
821
+ list.value?.setItems(newItems);
822
+ });
823
+
824
+ onUnmounted(() => {
825
+ list.value?.destroy();
826
+ });
827
+
828
+ return { container };
829
+ },
830
+ };
716
831
  ```
717
832
 
718
- ### CSS Classes
833
+ ### Svelte
719
834
 
720
- The component uses these CSS class names:
721
-
722
- - `.vlist` - Root container
723
- - `.vlist-viewport` - Scrollable viewport
724
- - `.vlist-content` - Content container (sets total height)
725
- - `.vlist-items` - Items container
726
- - `.vlist-item` - Individual item
727
- - `.vlist-item--selected` - Selected item
728
- - `.vlist-item--focused` - Focused item (keyboard nav)
729
- - `.vlist--grid` - Grid layout modifier
730
- - `.vlist-grid-item` - Grid item (positioned with `translate(x, y)`)
731
- - `.vlist--grouped` - Grouped list modifier
732
- - `.vlist-sticky-header` - Sticky header overlay
733
- - `.vlist-live-region` - Visually-hidden live region for screen reader announcements
734
- - `.vlist--scrolling` - Applied during active scroll (disables transitions)
835
+ ```typescript
836
+ <script>
837
+ import { vlist, withSelection } from 'vlist';
838
+ import { onMount, onDestroy } from 'svelte';
839
+
840
+ export let items = [];
841
+
842
+ let container;
843
+ let list;
844
+
845
+ onMount(() => {
846
+ list = vlist({
847
+ container,
848
+ items,
849
+ item: { height: 48, template: renderItem },
850
+ })
851
+ .use(withSelection({ mode: 'single' }))
852
+ .build();
853
+ });
735
854
 
736
- ### Variants
855
+ $: list?.setItems(items);
737
856
 
738
- Import `vlist/styles/extras` for these variant classes:
857
+ onDestroy(() => {
858
+ list?.destroy();
859
+ });
860
+ </script>
739
861
 
740
- ```html
741
- <!-- Compact spacing -->
742
- <div class="vlist vlist--compact">...</div>
862
+ <div bind:this={container}></div>
863
+ ```
743
864
 
744
- <!-- Comfortable spacing -->
745
- <div class="vlist vlist--comfortable">...</div>
865
+ ## Styling
746
866
 
747
- <!-- No borders -->
748
- <div class="vlist vlist--borderless">...</div>
867
+ Import the base styles:
749
868
 
750
- <!-- Striped rows -->
751
- <div class="vlist vlist--striped">...</div>
869
+ ```typescript
870
+ import 'vlist/styles';
752
871
  ```
753
872
 
754
- ### CSS Custom Properties
755
-
756
- All visual aspects can be customized via CSS custom properties:
873
+ Or customize with your own CSS:
757
874
 
758
875
  ```css
759
- :root {
760
- --vlist-bg: #ffffff;
761
- --vlist-bg-hover: #f9fafb;
762
- --vlist-bg-selected: #eff6ff;
763
- --vlist-border: #e5e7eb;
764
- --vlist-text: #111827;
765
- --vlist-focus-ring: #3b82f6;
766
- --vlist-item-padding-x: 1rem;
767
- --vlist-item-padding-y: 0.75rem;
768
- --vlist-border-radius: 0.5rem;
769
- --vlist-transition-duration: 150ms;
876
+ .vlist {
877
+ /* Container styles */
878
+ }
879
+
880
+ .vlist-viewport {
881
+ /* Scroll viewport */
882
+ }
883
+
884
+ .vlist-item {
885
+ /* Item wrapper - positioned absolutely */
886
+ }
887
+
888
+ .vlist-item--selected {
889
+ /* Selected state */
890
+ }
891
+
892
+ .vlist-item--focused {
893
+ /* Focused state (keyboard navigation) */
894
+ }
895
+
896
+ .vlist-scrollbar {
897
+ /* Custom scrollbar track */
898
+ }
899
+
900
+ .vlist-scrollbar__thumb {
901
+ /* Scrollbar thumb/handle */
770
902
  }
771
903
  ```
772
904
 
773
- Dark mode is supported automatically via `prefers-color-scheme: dark` or the `.dark` class.
905
+ ## Performance
906
+
907
+ ### Bundle Sizes (Gzipped)
908
+
909
+ | Configuration | Size | What's Included |
910
+ |---------------|------|-----------------|
911
+ | Base only | 7.7 KB | Core virtualization |
912
+ | + Grid | 11.7 KB | + 2D layout |
913
+ | + Sections | 12.3 KB | + Grouped lists |
914
+ | + Selection | 10.0 KB | + Item selection |
915
+ | + Async | 13.5 KB | + Data loading |
916
+ | + Scale | 9.9 KB | + 1M+ items |
917
+ | + All plugins | ~16 KB | Everything |
918
+
919
+ **Traditional virtual lists:** 20-23 KB minimum (all features bundled regardless of usage)
920
+
921
+ ### Memory Efficiency
922
+
923
+ With 100,000 items at 48px each:
924
+ - **Total height:** 4,800,000 pixels
925
+ - **Visible items:** ~20 (depending on viewport height)
926
+ - **DOM nodes:** ~26 (visible + overscan)
927
+ - **Memory saved:** ~99.97% (26 DOM nodes vs 100,000)
928
+
929
+ ### Benchmarks
930
+
931
+ See [benchmarks](https://vlist.dev/benchmarks/) for detailed performance comparisons.
932
+
933
+ ## Browser Support
934
+
935
+ - Chrome/Edge: Latest 2 versions
936
+ - Firefox: Latest 2 versions
937
+ - Safari: Latest 2 versions
938
+ - iOS Safari: 12.4+
939
+ - Chrome Android: Latest
940
+
941
+ Relies on:
942
+ - `IntersectionObserver` (widely supported)
943
+ - `ResizeObserver` (polyfill available if needed)
944
+ - CSS `transform` (universal support)
774
945
 
775
946
  ## TypeScript
776
947
 
777
- Full TypeScript support with generics:
948
+ Fully typed with comprehensive TypeScript definitions:
778
949
 
779
950
  ```typescript
780
- interface User {
951
+ import { vlist, withGrid, type VList, type VListConfig } from 'vlist';
952
+
953
+ interface Photo {
781
954
  id: number;
782
- name: string;
783
- email: string;
955
+ url: string;
956
+ title: string;
784
957
  }
785
958
 
786
- const list = createVList<User>({
787
- container: '#users',
959
+ const config: VListConfig<Photo> = {
960
+ container: '#gallery',
961
+ items: photos,
788
962
  item: {
789
- height: 48,
790
- template: (user) => `<div>${user.name} - ${user.email}</div>`,
963
+ height: 200,
964
+ template: (photo: Photo) => `<img src="${photo.url}" />`,
791
965
  },
792
- items: users,
793
- });
966
+ };
794
967
 
795
- // Fully typed
796
- list.on('item:click', ({ item }) => {
797
- console.log(item.email); // TypeScript knows this is a User
798
- });
968
+ const list: VList<Photo> = vlist(config)
969
+ .use(withGrid({ columns: 4 }))
970
+ .build();
799
971
  ```
800
972
 
801
- ## Performance
802
-
803
- vlist is designed for maximum performance with extensive built-in optimizations:
973
+ ## Migration from Monolithic API
804
974
 
805
- - **Virtual rendering** - Only visible items + overscan buffer are in the DOM
806
- - **Element pooling** - DOM elements are recycled via `createElementPool()`, reducing GC pressure
807
- - **Zero-allocation scroll hot path** - No object allocations per scroll frame; in-place range mutation
808
- - **RAF-throttled native scroll** - At most one scroll processing per animation frame
809
- - **CSS containment** - `contain: layout style` on items container, `contain: content` + `will-change: transform` on items
810
- - **Scroll transition suppression** - `.vlist--scrolling` class disables CSS transitions during active scroll
811
- - **Circular buffer velocity tracker** - Pre-allocated buffer, zero allocations during scroll
812
- - **Targeted keyboard focus render** - Arrow keys update only 2 affected items instead of all visible items
813
- - **Batched LRU timestamps** - Single `Date.now()` per render cycle instead of per-item
814
- - **DocumentFragment batching** - New elements appended in a single DOM operation
815
- - **Split CSS** - Core styles (5.6 KB) separated from optional extras (1.8 KB)
816
- - **Configurable velocity-based loading** - Skip, preload, or defer loading based on scroll speed
817
- - **Compression for 1M+ items** - Automatic scroll space compression when content exceeds browser height limits
975
+ If you're using the old monolithic API:
818
976
 
819
- ### Benchmark Results
977
+ ### Before (Monolithic - Deprecated)
820
978
 
821
- Measured in Chrome (10-core Mac, 60Hz display) via the [live benchmark page](https://vlist.dev/benchmarks/):
979
+ ```typescript
980
+ import { createVList } from 'vlist';
822
981
 
823
- | Metric | 10K items | 1M items |
824
- |--------|-----------|----------|
825
- | Initial render | ~32ms | ~135ms |
826
- | Scroll FPS | 60fps | 61fps |
827
- | Frame budget (avg) | 2.1ms | 1.9ms |
828
- | Frame budget (p95) | 4.2ms | 8.7ms |
829
- | Dropped frames | 0% | 0% |
830
- | scrollToIndex | ~166ms | ~82ms |
831
- | Memory (scroll delta) | 0 MB | 0 MB |
982
+ const list = createVList({
983
+ container: '#app',
984
+ items: data,
985
+ grid: { columns: 4 },
986
+ groups: { ... },
987
+ selection: { mode: 'single' },
988
+ });
989
+ ```
832
990
 
833
- Zero dropped frames and zero memory growth during sustained scrolling — even at 1M items.
991
+ ### After (Builder - Recommended)
834
992
 
835
- For the full optimization guide, see [docs/optimization.md](docs/optimization.md).
993
+ ```typescript
994
+ import { vlist, withGrid, withSections, withSelection } from 'vlist';
995
+
996
+ const list = vlist({
997
+ container: '#app',
998
+ items: data,
999
+ })
1000
+ .use(withGrid({ columns: 4 }))
1001
+ .use(withSections({ ... }))
1002
+ .use(withSelection({ mode: 'single' }))
1003
+ .build();
1004
+ ```
836
1005
 
837
- ## Browser Support
1006
+ **Benefits:**
1007
+ - 2-3x smaller bundles (20 KB → 8-12 KB gzipped)
1008
+ - Explicit about what's included
1009
+ - Better tree-shaking
1010
+ - Easier to understand and debug
838
1011
 
839
- - Chrome 60+
840
- - Firefox 55+
841
- - Safari 12+
842
- - Edge 79+
1012
+ ## Plugin Naming Changes
843
1013
 
844
- ## Contributing
1014
+ If you're upgrading from an earlier version:
845
1015
 
846
- Contributions are welcome! Please read our [contributing guidelines](CONTRIBUTING.md) first.
1016
+ | Old Name | New Name | Import |
1017
+ |----------|----------|--------|
1018
+ | `withCompression()` | `withScale()` | `import { withScale } from 'vlist'` |
1019
+ | `withData()` | `withAsync()` | `import { withAsync } from 'vlist'` |
1020
+ | `withWindow()` | `withPage()` | `import { withPage } from 'vlist'` |
1021
+ | `withGroups()` | `withSections()` | `import { withSections } from 'vlist'` |
1022
+ | `withScroll()` | `withScrollbar()` | `import { withScrollbar } from 'vlist'` |
847
1023
 
848
- ```bash
849
- # Install dependencies
850
- bun install
1024
+ ## Contributing
851
1025
 
852
- # Run development build
853
- bun run dev
1026
+ Contributions are welcome! Please:
854
1027
 
855
- # Run tests
856
- bun test
1028
+ 1. Fork the repository
1029
+ 2. Create a feature branch
1030
+ 3. Make your changes
1031
+ 4. Add/update tests
1032
+ 5. Submit a pull request
857
1033
 
858
- # Type check
859
- bun run typecheck
1034
+ ## License
860
1035
 
861
- # Build for production
862
- bun run build
863
- ```
1036
+ MIT License - see [LICENSE](LICENSE) for details
864
1037
 
865
- ## License
1038
+ ## Links
866
1039
 
867
- MIT © [Floor](https://github.com/floor)
1040
+ - **Documentation & Examples:** [vlist.dev](https://vlist.dev)
1041
+ - **GitHub:** [github.com/floor/vlist](https://github.com/floor/vlist)
1042
+ - **NPM:** [@floor/vlist](https://www.npmjs.com/package/@floor/vlist)
1043
+ - **Issues:** [GitHub Issues](https://github.com/floor/vlist/issues)
868
1044
 
869
- ## Credits
1045
+ ---
870
1046
 
871
- Inspired by the [mtrl-addons](https://github.com/floor/mtrl-addons) vlist component.
1047
+ **Built by [Floor IO](https://floor.io)** with ❤️ for the web community