@vielzeug/virtualit 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -0
- package/dist/dom/dom.cjs +2 -0
- package/dist/dom/dom.cjs.map +1 -0
- package/dist/dom/dom.d.ts +22 -0
- package/dist/dom/dom.d.ts.map +1 -0
- package/dist/dom/dom.js +47 -0
- package/dist/dom/dom.js.map +1 -0
- package/dist/dom/index.d.ts +2 -0
- package/dist/dom/index.d.ts.map +1 -0
- package/dist/dom.cjs +1 -0
- package/dist/dom.js +3 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/virtualit.cjs +2 -0
- package/dist/virtualit.cjs.map +1 -0
- package/dist/virtualit.d.ts +126 -0
- package/dist/virtualit.d.ts.map +1 -0
- package/dist/virtualit.js +129 -0
- package/dist/virtualit.js.map +1 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# @vielzeug/virtualit
|
|
2
|
+
|
|
3
|
+
> Lightweight, framework-agnostic virtual list engine for DOM rendering layers
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@vielzeug/virtualit) [](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
**Virtualit** renders only the items visible in the viewport plus a configurable overscan buffer. It uses a `ResizeObserver` for automatic container remeasurement and a passive `scroll` listener to keep the visible window in sync — no framework required.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pnpm add @vielzeug/virtualit
|
|
13
|
+
# npm install @vielzeug/virtualit
|
|
14
|
+
# yarn add @vielzeug/virtualit
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quick Start
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { createVirtualizer } from '@vielzeug/virtualit';
|
|
21
|
+
|
|
22
|
+
const scrollEl = document.querySelector<HTMLElement>('.scroll-container')!;
|
|
23
|
+
|
|
24
|
+
const virt = createVirtualizer(scrollEl, {
|
|
25
|
+
count: items.length,
|
|
26
|
+
estimateSize: 36,
|
|
27
|
+
onChange: (virtualItems, totalSize) => {
|
|
28
|
+
spacer.style.height = `${totalSize}px`;
|
|
29
|
+
list.innerHTML = '';
|
|
30
|
+
|
|
31
|
+
for (const item of virtualItems) {
|
|
32
|
+
const row = document.createElement('div');
|
|
33
|
+
row.style.cssText = `position:absolute;top:${item.top}px;left:0;right:0;height:${item.height}px;`;
|
|
34
|
+
row.textContent = items[item.index].label;
|
|
35
|
+
list.appendChild(row);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Later:
|
|
41
|
+
virt.destroy();
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- ✅ **Framework-agnostic** — callback-based `onChange`; works with React, Vue, Svelte, Lit, or vanilla DOM
|
|
47
|
+
- ✅ **Fixed and variable heights** — pass a number or a per-index estimator; call `measureElement()` for exact heights
|
|
48
|
+
- ✅ **Batched measurements** — `measureElement()` calls within a single tick are coalesced into one rebuild via `queueMicrotask`
|
|
49
|
+
- ✅ **Skipped re-renders** — `onChange` is not fired when a scroll event doesn't cross an item boundary
|
|
50
|
+
- ✅ **Programmatic scrolling** — `scrollToIndex()` with `start`, `end`, `center`, and `auto` alignment; `scrollToOffset()` for pixel-level control; both support `behavior: 'smooth'`
|
|
51
|
+
- ✅ **Reactive count and density** — `count` and `estimateSize` setters rebuild and re-render automatically
|
|
52
|
+
- ✅ **Typed Float64Array offsets** — dense contiguous buffer for cache-friendly binary search
|
|
53
|
+
- ✅ **Disposable** — implements `[Symbol.dispose]` for `using` declarations
|
|
54
|
+
- ✅ **Zero dependencies**
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
### Vanilla DOM
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { createVirtualizer } from '@vielzeug/virtualit';
|
|
62
|
+
|
|
63
|
+
const scrollEl = document.getElementById('scroll')!;
|
|
64
|
+
const spacer = document.getElementById('spacer')!;
|
|
65
|
+
const list = document.getElementById('list')!;
|
|
66
|
+
|
|
67
|
+
const virt = createVirtualizer(scrollEl, {
|
|
68
|
+
count: 10_000,
|
|
69
|
+
estimateSize: 36,
|
|
70
|
+
overscan: 3,
|
|
71
|
+
onChange: (virtualItems, totalSize) => {
|
|
72
|
+
spacer.style.height = `${totalSize}px`;
|
|
73
|
+
list.innerHTML = '';
|
|
74
|
+
|
|
75
|
+
for (const item of virtualItems) {
|
|
76
|
+
const el = document.createElement('div');
|
|
77
|
+
el.style.cssText = `position:absolute;top:${item.top}px;`;
|
|
78
|
+
el.textContent = `Row ${item.index}`;
|
|
79
|
+
list.appendChild(el);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Variable Heights
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
const virt = createVirtualizer(scrollEl, {
|
|
89
|
+
count: items.length,
|
|
90
|
+
estimateSize: (i) => (items[i].isHeader ? 48 : 36),
|
|
91
|
+
onChange: (virtualItems, totalSize) => {
|
|
92
|
+
// render items...
|
|
93
|
+
|
|
94
|
+
// After rendering, report the actual measured heights
|
|
95
|
+
for (const item of virtualItems) {
|
|
96
|
+
const el = list.querySelector(`[data-index="${item.index}"]`) as HTMLElement | null;
|
|
97
|
+
if (el) virt.measureElement(item.index, el.offsetHeight);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Programmatic Scroll
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
// Jump to item 200, centered in viewport
|
|
107
|
+
virt.scrollToIndex(200, { align: 'center' });
|
|
108
|
+
|
|
109
|
+
// Smooth scroll to item 50
|
|
110
|
+
virt.scrollToIndex(50, { align: 'start', behavior: 'smooth' });
|
|
111
|
+
|
|
112
|
+
// Scroll to a known pixel offset
|
|
113
|
+
virt.scrollToOffset(1440, { behavior: 'smooth' });
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Updating the List
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// Append more items — setter rebuilds and re-renders automatically
|
|
120
|
+
virt.count = newItems.length;
|
|
121
|
+
|
|
122
|
+
// Switch row density (e.g. compact ↔ comfortable view)
|
|
123
|
+
virt.estimateSize = isDense ? 32 : 48;
|
|
124
|
+
|
|
125
|
+
// Recompute after a font swap or layout shift
|
|
126
|
+
virt.invalidate();
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Explicit Resource Management
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// `using` automatically calls virt.destroy() when the block exits
|
|
133
|
+
{
|
|
134
|
+
using virt = createVirtualizer(scrollEl, { count: 100 });
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## API
|
|
139
|
+
|
|
140
|
+
### Package Exports
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
export { Virtualizer, createVirtualizer } from '@vielzeug/virtualit';
|
|
144
|
+
export type { ScrollToIndexOptions, VirtualItem, VirtualizerOptions } from '@vielzeug/virtualit';
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### `createVirtualizer(el, options)`
|
|
148
|
+
|
|
149
|
+
Creates and immediately attaches a `Virtualizer` to `el`.
|
|
150
|
+
|
|
151
|
+
| Parameter | Type | Description |
|
|
152
|
+
| ---------------------- | --------------------------------------------------- | ------------------------------------------------------ |
|
|
153
|
+
| `el` | `HTMLElement` | The scroll container to observe |
|
|
154
|
+
| `options.count` | `number` | Total number of items |
|
|
155
|
+
| `options.estimateSize` | `number \| (i: number) => number` | Row height estimate. Default: `36` |
|
|
156
|
+
| `options.overscan` | `number` | Items to render beyond the viewport edge. Default: `3` |
|
|
157
|
+
| `options.onChange` | `(items: VirtualItem[], totalSize: number) => void` | Called whenever the visible range changes |
|
|
158
|
+
|
|
159
|
+
Returns a `Virtualizer` instance.
|
|
160
|
+
|
|
161
|
+
### `Virtualizer`
|
|
162
|
+
|
|
163
|
+
| Member | Type | Description |
|
|
164
|
+
| --------------------------- | ----------------------------------------- | -------------------------------------------------- |
|
|
165
|
+
| `count` | `get/set number` | Item count; setting rebuilds and re-renders |
|
|
166
|
+
| `estimateSize` | `set number \| (i) => number` | Update the size estimator; clears measured heights |
|
|
167
|
+
| `attach(el)` | `(el: HTMLElement) => void` | Attach (or re-attach) to a scroll container |
|
|
168
|
+
| `destroy()` | `() => void` | Remove all listeners; idempotent |
|
|
169
|
+
| `[Symbol.dispose]()` | — | Delegates to `destroy()` |
|
|
170
|
+
| `getVirtualItems()` | `() => VirtualItem[]` | Currently rendered items |
|
|
171
|
+
| `getTotalSize()` | `() => number` | Total scrollable height in px |
|
|
172
|
+
| `measureElement(i, h)` | `(index: number, height: number) => void` | Record an exact item height; batched per microtask |
|
|
173
|
+
| `scrollToIndex(i, opts?)` | `(index, ScrollToIndexOptions) => void` | Scroll to an item |
|
|
174
|
+
| `scrollToOffset(px, opts?)` | `(offset, { behavior? }) => void` | Scroll to a pixel offset |
|
|
175
|
+
| `invalidate()` | `() => void` | Clear all measured heights and re-render |
|
|
176
|
+
|
|
177
|
+
### `VirtualItem`
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
interface VirtualItem {
|
|
181
|
+
index: number; // Position in the full list
|
|
182
|
+
top: number; // Pixel offset from the top of the scroll area
|
|
183
|
+
height: number; // Measured or estimated height
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## Documentation
|
|
188
|
+
|
|
189
|
+
Full docs at **[vielzeug.dev/virtualit](https://vielzeug.dev/virtualit)**
|
|
190
|
+
|
|
191
|
+
| | |
|
|
192
|
+
|---|---|
|
|
193
|
+
| [Usage Guide](https://vielzeug.dev/virtualit/usage) | Fixed/variable heights, overscan, scrolling |
|
|
194
|
+
| [API Reference](https://vielzeug.dev/virtualit/api) | Complete type signatures |
|
|
195
|
+
| [Examples](https://vielzeug.dev/virtualit/examples) | Real-world virtual list patterns |
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
MIT © [Helmuth Saatkamp](https://github.com/helmuthdu) — Part of the [Vielzeug](https://github.com/helmuthdu/vielzeug) monorepo.
|
package/dist/dom/dom.cjs
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
const e=require(`../virtualit.cjs`);function t(t){let n=[],r=null,i=null,a=null,o=e=>typeof t.estimateSize==`number`?t.estimateSize:t.estimateSize(e,n[e]),s=()=>{!r||!a||(r.style.height=`${a.getTotalSize()}px`,r.style.position=`relative`,r.style.contain=`layout`)},c=()=>{r&&(t.clear(r),r.style.height=``,r.style.position=``,r.style.contain=``)},l=()=>{let l=t.getScrollElement(),u=t.getListElement();if(!l||!u||n.length===0){a?.destroy(),a=null,r=u,i=l,c();return}let d=i!==l||r!==u;r=u,i=l,!a||d?(a?.destroy(),a=e.createVirtualizer(i,{count:n.length,estimateSize:o,onChange:e=>{r&&t.render({items:n,listEl:r,virtualItems:e})},overscan:t.overscan??3})):(a.count=n.length,a.invalidate()),s()};return{destroy(){a?.destroy(),a=null,c()},scrollToIndex(e,t){a?.scrollToIndex(e,t)},update(e,o){if(n=e,!o||n.length===0){a?.destroy(),a=null,r=t.getListElement(),i=t.getScrollElement(),c();return}l()}}}exports.createDomVirtualList=t;
|
|
2
|
+
//# sourceMappingURL=dom.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.cjs","names":[],"sources":["../../src/dom/dom.ts"],"sourcesContent":["import { type ScrollToIndexOptions, type VirtualItem, type Virtualizer, createVirtualizer } from '../virtualit';\n\nexport * from '../virtualit';\n\nexport type DomVirtualListRenderArgs<T> = {\n items: T[];\n listEl: HTMLElement;\n virtualItems: VirtualItem[];\n};\n\nexport type DomVirtualListOptions<T> = {\n clear: (listEl: HTMLElement) => void;\n estimateSize: number | ((index: number, item: T) => number);\n getListElement: () => HTMLElement | null;\n getScrollElement: () => HTMLElement | null;\n overscan?: number;\n render: (args: DomVirtualListRenderArgs<T>) => void;\n};\n\nexport type DomVirtualListController<T> = {\n destroy: () => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n update: (items: T[], enabled: boolean) => void;\n};\n\nexport function createDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T> {\n let currentItems: T[] = [];\n let listElRef: HTMLElement | null = null;\n let scrollElRef: HTMLElement | null = null;\n let virtualizer: Virtualizer | null = null;\n\n const resolveEstimate = (index: number): number => {\n if (typeof options.estimateSize === 'number') return options.estimateSize;\n\n return options.estimateSize(index, currentItems[index]!);\n };\n\n const applyListStyles = () => {\n if (!listElRef || !virtualizer) return;\n\n listElRef.style.height = `${virtualizer.getTotalSize()}px`;\n listElRef.style.position = 'relative';\n listElRef.style.contain = 'layout';\n };\n\n const clearAndReset = () => {\n if (!listElRef) return;\n\n options.clear(listElRef);\n listElRef.style.height = '';\n listElRef.style.position = '';\n listElRef.style.contain = '';\n };\n\n const ensureVirtualizer = () => {\n const nextScroll = options.getScrollElement();\n const nextList = options.getListElement();\n\n if (!nextScroll || !nextList || currentItems.length === 0) {\n virtualizer?.destroy();\n virtualizer = null;\n listElRef = nextList;\n scrollElRef = nextScroll;\n clearAndReset();\n\n return;\n }\n\n const targetChanged = scrollElRef !== nextScroll || listElRef !== nextList;\n\n listElRef = nextList;\n scrollElRef = nextScroll;\n\n if (!virtualizer || targetChanged) {\n virtualizer?.destroy();\n virtualizer = createVirtualizer(scrollElRef, {\n count: currentItems.length,\n estimateSize: resolveEstimate,\n onChange: (virtualItems) => {\n if (!listElRef) return;\n\n options.render({ items: currentItems, listEl: listElRef, virtualItems });\n },\n overscan: options.overscan ?? 3,\n });\n } else {\n virtualizer.count = currentItems.length;\n virtualizer.invalidate();\n }\n\n applyListStyles();\n };\n\n return {\n destroy() {\n virtualizer?.destroy();\n virtualizer = null;\n clearAndReset();\n },\n scrollToIndex(index, scrollOptions) {\n virtualizer?.scrollToIndex(index, scrollOptions);\n },\n update(items, enabled) {\n currentItems = items;\n\n if (!enabled || currentItems.length === 0) {\n virtualizer?.destroy();\n virtualizer = null;\n listElRef = options.getListElement();\n scrollElRef = options.getScrollElement();\n clearAndReset();\n\n return;\n }\n\n ensureVirtualizer();\n },\n };\n}\n"],"mappings":"oCAyBA,SAAgB,EAAwB,EAAgE,CACtG,IAAI,EAAoB,EAAE,CACtB,EAAgC,KAChC,EAAkC,KAClC,EAAkC,KAEhC,EAAmB,GACnB,OAAO,EAAQ,cAAiB,SAAiB,EAAQ,aAEtD,EAAQ,aAAa,EAAO,EAAa,GAAQ,CAGpD,MAAwB,CACxB,CAAC,GAAa,CAAC,IAEnB,EAAU,MAAM,OAAS,GAAG,EAAY,cAAc,CAAC,IACvD,EAAU,MAAM,SAAW,WAC3B,EAAU,MAAM,QAAU,WAGtB,MAAsB,CACrB,IAEL,EAAQ,MAAM,EAAU,CACxB,EAAU,MAAM,OAAS,GACzB,EAAU,MAAM,SAAW,GAC3B,EAAU,MAAM,QAAU,KAGtB,MAA0B,CAC9B,IAAM,EAAa,EAAQ,kBAAkB,CACvC,EAAW,EAAQ,gBAAgB,CAEzC,GAAI,CAAC,GAAc,CAAC,GAAY,EAAa,SAAW,EAAG,CACzD,GAAa,SAAS,CACtB,EAAc,KACd,EAAY,EACZ,EAAc,EACd,GAAe,CAEf,OAGF,IAAM,EAAgB,IAAgB,GAAc,IAAc,EAElE,EAAY,EACZ,EAAc,EAEV,CAAC,GAAe,GAClB,GAAa,SAAS,CACtB,EAAc,EAAA,kBAAkB,EAAa,CAC3C,MAAO,EAAa,OACpB,aAAc,EACd,SAAW,GAAiB,CACrB,GAEL,EAAQ,OAAO,CAAE,MAAO,EAAc,OAAQ,EAAW,eAAc,CAAC,EAE1E,SAAU,EAAQ,UAAY,EAC/B,CAAC,GAEF,EAAY,MAAQ,EAAa,OACjC,EAAY,YAAY,EAG1B,GAAiB,EAGnB,MAAO,CACL,SAAU,CACR,GAAa,SAAS,CACtB,EAAc,KACd,GAAe,EAEjB,cAAc,EAAO,EAAe,CAClC,GAAa,cAAc,EAAO,EAAc,EAElD,OAAO,EAAO,EAAS,CAGrB,GAFA,EAAe,EAEX,CAAC,GAAW,EAAa,SAAW,EAAG,CACzC,GAAa,SAAS,CACtB,EAAc,KACd,EAAY,EAAQ,gBAAgB,CACpC,EAAc,EAAQ,kBAAkB,CACxC,GAAe,CAEf,OAGF,GAAmB,EAEtB"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type ScrollToIndexOptions, type VirtualItem } from '../virtualit';
|
|
2
|
+
export * from '../virtualit';
|
|
3
|
+
export type DomVirtualListRenderArgs<T> = {
|
|
4
|
+
items: T[];
|
|
5
|
+
listEl: HTMLElement;
|
|
6
|
+
virtualItems: VirtualItem[];
|
|
7
|
+
};
|
|
8
|
+
export type DomVirtualListOptions<T> = {
|
|
9
|
+
clear: (listEl: HTMLElement) => void;
|
|
10
|
+
estimateSize: number | ((index: number, item: T) => number);
|
|
11
|
+
getListElement: () => HTMLElement | null;
|
|
12
|
+
getScrollElement: () => HTMLElement | null;
|
|
13
|
+
overscan?: number;
|
|
14
|
+
render: (args: DomVirtualListRenderArgs<T>) => void;
|
|
15
|
+
};
|
|
16
|
+
export type DomVirtualListController<T> = {
|
|
17
|
+
destroy: () => void;
|
|
18
|
+
scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;
|
|
19
|
+
update: (items: T[], enabled: boolean) => void;
|
|
20
|
+
};
|
|
21
|
+
export declare function createDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T>;
|
|
22
|
+
//# sourceMappingURL=dom.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.d.ts","sourceRoot":"","sources":["../../src/dom/dom.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,oBAAoB,EAAE,KAAK,WAAW,EAAuC,MAAM,cAAc,CAAC;AAEhH,cAAc,cAAc,CAAC;AAE7B,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC,KAAK,EAAE,CAAC,EAAE,CAAC;IACX,MAAM,EAAE,WAAW,CAAC;IACpB,YAAY,EAAE,WAAW,EAAE,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,qBAAqB,CAAC,CAAC,IAAI;IACrC,KAAK,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;IACrC,YAAY,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,KAAK,MAAM,CAAC,CAAC;IAC5D,cAAc,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IACzC,gBAAgB,EAAE,MAAM,WAAW,GAAG,IAAI,CAAC;IAC3C,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,CAAC,IAAI,EAAE,wBAAwB,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC;CACrD,CAAC;AAEF,MAAM,MAAM,wBAAwB,CAAC,CAAC,IAAI;IACxC,OAAO,EAAE,MAAM,IAAI,CAAC;IACpB,aAAa,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,oBAAoB,KAAK,IAAI,CAAC;IACvE,MAAM,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;CAChD,CAAC;AAEF,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,OAAO,EAAE,qBAAqB,CAAC,CAAC,CAAC,GAAG,wBAAwB,CAAC,CAAC,CAAC,CA6FtG"}
|
package/dist/dom/dom.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createVirtualizer as e } from "../virtualit.js";
|
|
2
|
+
//#region src/dom/dom.ts
|
|
3
|
+
function t(t) {
|
|
4
|
+
let n = [], r = null, i = null, a = null, o = (e) => typeof t.estimateSize == "number" ? t.estimateSize : t.estimateSize(e, n[e]), s = () => {
|
|
5
|
+
!r || !a || (r.style.height = `${a.getTotalSize()}px`, r.style.position = "relative", r.style.contain = "layout");
|
|
6
|
+
}, c = () => {
|
|
7
|
+
r && (t.clear(r), r.style.height = "", r.style.position = "", r.style.contain = "");
|
|
8
|
+
}, l = () => {
|
|
9
|
+
let l = t.getScrollElement(), u = t.getListElement();
|
|
10
|
+
if (!l || !u || n.length === 0) {
|
|
11
|
+
a?.destroy(), a = null, r = u, i = l, c();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
let d = i !== l || r !== u;
|
|
15
|
+
r = u, i = l, !a || d ? (a?.destroy(), a = e(i, {
|
|
16
|
+
count: n.length,
|
|
17
|
+
estimateSize: o,
|
|
18
|
+
onChange: (e) => {
|
|
19
|
+
r && t.render({
|
|
20
|
+
items: n,
|
|
21
|
+
listEl: r,
|
|
22
|
+
virtualItems: e
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
overscan: t.overscan ?? 3
|
|
26
|
+
})) : (a.count = n.length, a.invalidate()), s();
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
destroy() {
|
|
30
|
+
a?.destroy(), a = null, c();
|
|
31
|
+
},
|
|
32
|
+
scrollToIndex(e, t) {
|
|
33
|
+
a?.scrollToIndex(e, t);
|
|
34
|
+
},
|
|
35
|
+
update(e, o) {
|
|
36
|
+
if (n = e, !o || n.length === 0) {
|
|
37
|
+
a?.destroy(), a = null, r = t.getListElement(), i = t.getScrollElement(), c();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
l();
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
//#endregion
|
|
45
|
+
export { t as createDomVirtualList };
|
|
46
|
+
|
|
47
|
+
//# sourceMappingURL=dom.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dom.js","names":[],"sources":["../../src/dom/dom.ts"],"sourcesContent":["import { type ScrollToIndexOptions, type VirtualItem, type Virtualizer, createVirtualizer } from '../virtualit';\n\nexport * from '../virtualit';\n\nexport type DomVirtualListRenderArgs<T> = {\n items: T[];\n listEl: HTMLElement;\n virtualItems: VirtualItem[];\n};\n\nexport type DomVirtualListOptions<T> = {\n clear: (listEl: HTMLElement) => void;\n estimateSize: number | ((index: number, item: T) => number);\n getListElement: () => HTMLElement | null;\n getScrollElement: () => HTMLElement | null;\n overscan?: number;\n render: (args: DomVirtualListRenderArgs<T>) => void;\n};\n\nexport type DomVirtualListController<T> = {\n destroy: () => void;\n scrollToIndex: (index: number, options?: ScrollToIndexOptions) => void;\n update: (items: T[], enabled: boolean) => void;\n};\n\nexport function createDomVirtualList<T>(options: DomVirtualListOptions<T>): DomVirtualListController<T> {\n let currentItems: T[] = [];\n let listElRef: HTMLElement | null = null;\n let scrollElRef: HTMLElement | null = null;\n let virtualizer: Virtualizer | null = null;\n\n const resolveEstimate = (index: number): number => {\n if (typeof options.estimateSize === 'number') return options.estimateSize;\n\n return options.estimateSize(index, currentItems[index]!);\n };\n\n const applyListStyles = () => {\n if (!listElRef || !virtualizer) return;\n\n listElRef.style.height = `${virtualizer.getTotalSize()}px`;\n listElRef.style.position = 'relative';\n listElRef.style.contain = 'layout';\n };\n\n const clearAndReset = () => {\n if (!listElRef) return;\n\n options.clear(listElRef);\n listElRef.style.height = '';\n listElRef.style.position = '';\n listElRef.style.contain = '';\n };\n\n const ensureVirtualizer = () => {\n const nextScroll = options.getScrollElement();\n const nextList = options.getListElement();\n\n if (!nextScroll || !nextList || currentItems.length === 0) {\n virtualizer?.destroy();\n virtualizer = null;\n listElRef = nextList;\n scrollElRef = nextScroll;\n clearAndReset();\n\n return;\n }\n\n const targetChanged = scrollElRef !== nextScroll || listElRef !== nextList;\n\n listElRef = nextList;\n scrollElRef = nextScroll;\n\n if (!virtualizer || targetChanged) {\n virtualizer?.destroy();\n virtualizer = createVirtualizer(scrollElRef, {\n count: currentItems.length,\n estimateSize: resolveEstimate,\n onChange: (virtualItems) => {\n if (!listElRef) return;\n\n options.render({ items: currentItems, listEl: listElRef, virtualItems });\n },\n overscan: options.overscan ?? 3,\n });\n } else {\n virtualizer.count = currentItems.length;\n virtualizer.invalidate();\n }\n\n applyListStyles();\n };\n\n return {\n destroy() {\n virtualizer?.destroy();\n virtualizer = null;\n clearAndReset();\n },\n scrollToIndex(index, scrollOptions) {\n virtualizer?.scrollToIndex(index, scrollOptions);\n },\n update(items, enabled) {\n currentItems = items;\n\n if (!enabled || currentItems.length === 0) {\n virtualizer?.destroy();\n virtualizer = null;\n listElRef = options.getListElement();\n scrollElRef = options.getScrollElement();\n clearAndReset();\n\n return;\n }\n\n ensureVirtualizer();\n },\n };\n}\n"],"mappings":";;AAyBA,SAAgB,EAAwB,GAAgE;CACtG,IAAI,IAAoB,EAAE,EACtB,IAAgC,MAChC,IAAkC,MAClC,IAAkC,MAEhC,KAAmB,MACnB,OAAO,EAAQ,gBAAiB,WAAiB,EAAQ,eAEtD,EAAQ,aAAa,GAAO,EAAa,GAAQ,EAGpD,UAAwB;AACxB,GAAC,KAAa,CAAC,MAEnB,EAAU,MAAM,SAAS,GAAG,EAAY,cAAc,CAAC,KACvD,EAAU,MAAM,WAAW,YAC3B,EAAU,MAAM,UAAU;IAGtB,UAAsB;AACrB,QAEL,EAAQ,MAAM,EAAU,EACxB,EAAU,MAAM,SAAS,IACzB,EAAU,MAAM,WAAW,IAC3B,EAAU,MAAM,UAAU;IAGtB,UAA0B;EAC9B,IAAM,IAAa,EAAQ,kBAAkB,EACvC,IAAW,EAAQ,gBAAgB;AAEzC,MAAI,CAAC,KAAc,CAAC,KAAY,EAAa,WAAW,GAAG;AAKzD,GAJA,GAAa,SAAS,EACtB,IAAc,MACd,IAAY,GACZ,IAAc,GACd,GAAe;AAEf;;EAGF,IAAM,IAAgB,MAAgB,KAAc,MAAc;AAsBlE,EApBA,IAAY,GACZ,IAAc,GAEV,CAAC,KAAe,KAClB,GAAa,SAAS,EACtB,IAAc,EAAkB,GAAa;GAC3C,OAAO,EAAa;GACpB,cAAc;GACd,WAAW,MAAiB;AACrB,SAEL,EAAQ,OAAO;KAAE,OAAO;KAAc,QAAQ;KAAW;KAAc,CAAC;;GAE1E,UAAU,EAAQ,YAAY;GAC/B,CAAC,KAEF,EAAY,QAAQ,EAAa,QACjC,EAAY,YAAY,GAG1B,GAAiB;;AAGnB,QAAO;EACL,UAAU;AAGR,GAFA,GAAa,SAAS,EACtB,IAAc,MACd,GAAe;;EAEjB,cAAc,GAAO,GAAe;AAClC,MAAa,cAAc,GAAO,EAAc;;EAElD,OAAO,GAAO,GAAS;AAGrB,OAFA,IAAe,GAEX,CAAC,KAAW,EAAa,WAAW,GAAG;AAKzC,IAJA,GAAa,SAAS,EACtB,IAAc,MACd,IAAY,EAAQ,gBAAgB,EACpC,IAAc,EAAQ,kBAAkB,EACxC,GAAe;AAEf;;AAGF,MAAmB;;EAEtB"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/dom/index.ts"],"names":[],"mappings":"AAAA,cAAc,OAAO,CAAC"}
|
package/dist/dom.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./virtualit.cjs`),t=require(`./dom/dom.cjs`);exports.Virtualizer=e.Virtualizer,exports.createDomVirtualList=t.createDomVirtualList,exports.createVirtualizer=e.createVirtualizer;
|
package/dist/dom.js
ADDED
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./virtualit.cjs`);exports.Virtualizer=e.Virtualizer,exports.createVirtualizer=e.createVirtualizer;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
var e=class{_count;_estimateSizeFn;overscan;onChange;measuredHeights=new Map;virtualItems=[];totalSize=0;scrollOffsets=new Float64Array;containerHeight=0;scrollTop=0;prevRenderStart=-1;prevRenderEnd=-1;attachedEl=null;resizeObserver=null;scrollHandler=null;pendingBuild=!1;constructor(e){this._count=e.count;let t=e.estimateSize??36;this._estimateSizeFn=typeof t==`number`?()=>t:t,this.overscan=e.overscan??3,this.onChange=e.onChange,this.buildOffsets()}get count(){return this._count}set count(e){this._count=e,this.buildOffsets(),this.attachedEl&&this.computeVisible()}set estimateSize(e){this._estimateSizeFn=typeof e==`number`?()=>e:e,this.measuredHeights.clear(),this.buildOffsets(),this.attachedEl&&this.computeVisible()}attach(e){this.teardown(),this.attachedEl=e,this.containerHeight=e.clientHeight,this.scrollTop=e.scrollTop,this.scrollHandler=()=>{this.scrollTop=e.scrollTop,this.computeVisible()},e.addEventListener(`scroll`,this.scrollHandler,{passive:!0}),this.resizeObserver=new ResizeObserver(()=>{this.containerHeight=e.clientHeight,this.computeVisible()}),this.resizeObserver.observe(e),this.computeVisible()}destroy(){this.teardown()}[Symbol.dispose](){this.destroy()}getVirtualItems(){return this.virtualItems}getTotalSize(){return this.totalSize}measureElement(e,t){this.heightAt(e)!==t&&(this.measuredHeights.set(e,t),this.pendingBuild||(this.pendingBuild=!0,queueMicrotask(()=>{this.pendingBuild=!1,this.buildOffsets(),this.attachedEl&&this.computeVisible()})))}scrollToIndex(e,t={}){let n=this.attachedEl;if(!n)return;let r=Math.max(0,Math.min(e,this._count-1)),i=t.align??`auto`,a=t.behavior??`auto`,o=this.offsetAt(r),s=this.heightAt(r),c;if(i===`start`)c=o;else if(i===`end`)c=o+s-this.containerHeight;else if(i===`center`)c=o-(this.containerHeight-s)/2;else{let e=n.scrollTop,t=e+this.containerHeight;if(o>=e&&o+s<=t)return;c=o<e?o:o+s-this.containerHeight}n.scrollTo({behavior:a,top:Math.max(0,c)})}scrollToOffset(e,t={}){this.attachedEl?.scrollTo({behavior:t.behavior??`auto`,top:Math.max(0,e)})}invalidate(){this.measuredHeights.clear(),this.buildOffsets(),this.attachedEl&&this.computeVisible()}teardown(){this.scrollHandler&&this.attachedEl&&(this.attachedEl.removeEventListener(`scroll`,this.scrollHandler),this.scrollHandler=null),this.resizeObserver?.disconnect(),this.resizeObserver=null,this.attachedEl=null}heightAt(e){return this.measuredHeights.get(e)??this._estimateSizeFn(e)}offsetAt(e){return this.scrollOffsets[e]??0}buildOffsets(){this.prevRenderStart=-1,this.prevRenderEnd=-1;let e=new Float64Array(this._count+1);e[0]=0;for(let t=0;t<this._count;t++)e[t+1]=e[t]+this.heightAt(t);this.scrollOffsets=e,this.totalSize=e[this._count]??0}computeVisible(){let e=this.scrollTop,t=e+this.containerHeight,n=0,r=this._count-1;for(;n<r;){let t=n+r>>1;this.scrollOffsets[t+1]<=e?n=t+1:r=t}let i=n,a=i,o=this._count-1;for(;a<o;){let e=a+o+1>>1;this.scrollOffsets[e]<t?a=e:o=e-1}let s=a,c=Math.max(0,i-this.overscan),l=Math.min(this._count-1,s+this.overscan);if(c===this.prevRenderStart&&l===this.prevRenderEnd)return;this.prevRenderStart=c,this.prevRenderEnd=l;let u=[];for(let e=c;e<=l;e++)u.push({height:this.heightAt(e),index:e,top:this.scrollOffsets[e]});this.virtualItems=u,this.onChange?.(u,this.totalSize)}};function t(t,n){let r=new e(n);return r.attach(t),r}exports.Virtualizer=e,exports.createVirtualizer=t;
|
|
2
|
+
//# sourceMappingURL=virtualit.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtualit.cjs","names":[],"sources":["../src/virtualit.ts"],"sourcesContent":["/**\n * @vielzeug/virtualit — Lightweight virtual list / infinite-scroll engine.\n *\n * Framework-agnostic: works with any DOM rendering layer.\n * Uses a `ResizeObserver` to re-measure the scroll container and a\n * `scroll` listener to update the visible window.\n */\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface VirtualItem {\n /** Original item index in the full list */\n index: number;\n /** Pixel offset from the top of the virtual scroll area */\n top: number;\n /** Measured (or estimated) pixel height for this item */\n height: number;\n}\n\nexport interface VirtualizerOptions {\n /** Total number of items. */\n count: number;\n /**\n * Either a fixed row height or a per-index estimator function.\n * Defaults to 36px.\n */\n estimateSize?: number | ((index: number) => number);\n /**\n * Number of items to render outside the visible viewport on each side.\n * Higher values reduce blank-flash during fast scroll at the cost of more DOM nodes.\n * Defaults to 3.\n */\n overscan?: number;\n /**\n * Called whenever the visible range changes. Trigger your re-render here.\n */\n onChange?: (items: VirtualItem[], totalSize: number) => void;\n}\n\nexport interface ScrollToIndexOptions {\n /** 'start' | 'end' | 'center' | 'auto'. Defaults to 'auto'. */\n align?: 'start' | 'end' | 'center' | 'auto';\n /** Scroll behaviour. Defaults to 'auto'. */\n behavior?: ScrollBehavior;\n}\n\n// ─── Virtualizer ──────────────────────────────────────────────────────────────\n\nexport class Virtualizer {\n // mutable options\n private _count: number;\n private _estimateSizeFn: (index: number) => number;\n private overscan: number;\n private onChange: ((items: VirtualItem[], totalSize: number) => void) | undefined;\n\n // internal state\n private measuredHeights: Map<number, number> = new Map();\n private virtualItems: VirtualItem[] = [];\n private totalSize = 0;\n private scrollOffsets: Float64Array = new Float64Array(0); // prefix-sum cache\n private containerHeight = 0;\n private scrollTop = 0;\n\n // render range cache — reset in buildOffsets() so layout changes always re-render\n private prevRenderStart = -1;\n private prevRenderEnd = -1;\n\n // cleanup handles\n private attachedEl: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private scrollHandler: (() => void) | null = null;\n\n // batching flag for measureElement\n private pendingBuild = false;\n\n constructor(options: VirtualizerOptions) {\n this._count = options.count;\n\n const est = options.estimateSize ?? 36;\n\n this._estimateSizeFn = typeof est === 'number' ? () => est : est;\n this.overscan = options.overscan ?? 3;\n this.onChange = options.onChange;\n // Build the offset table eagerly; computeVisible is deferred to attach()\n // so the first onChange call always has a real containerHeight.\n this.buildOffsets();\n }\n\n // ─── Public API ───────────────────────────────────────────────────────────\n\n get count(): number {\n return this._count;\n }\n\n /** Setting count automatically rebuilds offsets and triggers a re-render. */\n set count(value: number) {\n this._count = value;\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n /**\n * Update the size estimator. Clears all measured heights and re-renders.\n * Useful when switching between row density modes (e.g. compact ↔ comfortable).\n */\n set estimateSize(fn: number | ((index: number) => number)) {\n this._estimateSizeFn = typeof fn === 'number' ? () => fn : fn;\n this.measuredHeights.clear();\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n /** Start observing the scroll container. */\n attach(el: HTMLElement): void {\n this.teardown();\n\n this.attachedEl = el;\n this.containerHeight = el.clientHeight;\n this.scrollTop = el.scrollTop;\n\n this.scrollHandler = () => {\n this.scrollTop = el.scrollTop;\n this.computeVisible();\n };\n el.addEventListener('scroll', this.scrollHandler, { passive: true });\n\n this.resizeObserver = new ResizeObserver(() => {\n this.containerHeight = el.clientHeight;\n // The offset table depends only on item heights, not container height —\n // no need to rebuild it here, only recompute the visible window.\n this.computeVisible();\n });\n this.resizeObserver.observe(el);\n\n this.computeVisible();\n }\n\n /** Stop observing and remove all listeners. */\n destroy(): void {\n this.teardown();\n }\n\n /** Supports the Explicit Resource Management `using` keyword. */\n [Symbol.dispose](): void {\n this.destroy();\n }\n\n /** Returns the currently visible virtual items. */\n getVirtualItems(): VirtualItem[] {\n return this.virtualItems;\n }\n\n /** Total pixel height of the entire list (set as the spacer height). */\n getTotalSize(): number {\n return this.totalSize;\n }\n\n /**\n * Record a measured height for a rendered item (for variable-height lists).\n *\n * Measurements are batched via microtask — safe to call for every item in a\n * render loop without incurring O(n²) rebuilds.\n */\n measureElement(index: number, height: number): void {\n if (this.heightAt(index) === height) return;\n\n this.measuredHeights.set(index, height);\n\n if (!this.pendingBuild) {\n this.pendingBuild = true;\n queueMicrotask(() => {\n this.pendingBuild = false;\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n });\n }\n }\n\n /** Programmatically scroll to a specific index. */\n scrollToIndex(index: number, options: ScrollToIndexOptions = {}): void {\n const el = this.attachedEl;\n\n if (!el) return;\n\n const clampedIndex = Math.max(0, Math.min(index, this._count - 1));\n const align = options.align ?? 'auto';\n const behavior = options.behavior ?? 'auto';\n const itemTop = this.offsetAt(clampedIndex);\n const itemHeight = this.heightAt(clampedIndex);\n\n let targetScrollTop: number;\n\n if (align === 'start') {\n targetScrollTop = itemTop;\n } else if (align === 'end') {\n targetScrollTop = itemTop + itemHeight - this.containerHeight;\n } else if (align === 'center') {\n targetScrollTop = itemTop - (this.containerHeight - itemHeight) / 2;\n } else {\n // auto: scroll only if not already visible\n const visibleStart = el.scrollTop;\n const visibleEnd = visibleStart + this.containerHeight;\n\n if (itemTop >= visibleStart && itemTop + itemHeight <= visibleEnd) return;\n\n targetScrollTop = itemTop < visibleStart ? itemTop : itemTop + itemHeight - this.containerHeight;\n }\n\n el.scrollTo({ behavior, top: Math.max(0, targetScrollTop) });\n }\n\n /** Programmatically scroll to a specific pixel offset. */\n scrollToOffset(offset: number, options: { behavior?: ScrollBehavior } = {}): void {\n this.attachedEl?.scrollTo({ behavior: options.behavior ?? 'auto', top: Math.max(0, offset) });\n }\n\n /**\n * Invalidate all item measurements. Call after a font load or layout shift\n * that changes item heights.\n */\n invalidate(): void {\n this.measuredHeights.clear();\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n // ─── Private Helpers ──────────────────────────────────────────────────────\n\n private teardown(): void {\n if (this.scrollHandler && this.attachedEl) {\n this.attachedEl.removeEventListener('scroll', this.scrollHandler);\n this.scrollHandler = null;\n }\n\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.attachedEl = null;\n }\n\n private heightAt(index: number): number {\n return this.measuredHeights.get(index) ?? this._estimateSizeFn(index);\n }\n\n private offsetAt(index: number): number {\n return this.scrollOffsets[index] ?? 0;\n }\n\n private buildOffsets(): void {\n // Invalidate the render range cache: item positions may shift even when the\n // visible index range stays the same (e.g. an item above grew taller).\n this.prevRenderStart = -1;\n this.prevRenderEnd = -1;\n\n const offsets = new Float64Array(this._count + 1);\n\n offsets[0] = 0;\n for (let i = 0; i < this._count; i++) {\n offsets[i + 1] = offsets[i] + this.heightAt(i);\n }\n this.scrollOffsets = offsets;\n this.totalSize = offsets[this._count] ?? 0;\n }\n\n private computeVisible(): void {\n const start = this.scrollTop;\n const end = start + this.containerHeight;\n\n // Binary search for the first visible index\n let lo = 0;\n let hi = this._count - 1;\n\n while (lo < hi) {\n const mid = (lo + hi) >> 1;\n\n if (this.scrollOffsets[mid + 1] <= start) lo = mid + 1;\n else hi = mid;\n }\n\n const firstVisible = lo;\n\n // Binary search for the last visible index\n let lo2 = firstVisible;\n let hi2 = this._count - 1;\n\n while (lo2 < hi2) {\n const mid = (lo2 + hi2 + 1) >> 1;\n\n if (this.scrollOffsets[mid] < end) lo2 = mid;\n else hi2 = mid - 1;\n }\n\n const lastVisible = lo2;\n const renderStart = Math.max(0, firstVisible - this.overscan);\n const renderEnd = Math.min(this._count - 1, lastVisible + this.overscan);\n\n // Skip re-render when the range is unchanged (e.g. a sub-pixel scroll that\n // doesn't cross an item boundary). The cache is reset in buildOffsets() so\n // any layout change always produces at least one render.\n if (renderStart === this.prevRenderStart && renderEnd === this.prevRenderEnd) return;\n\n this.prevRenderStart = renderStart;\n this.prevRenderEnd = renderEnd;\n\n const items: VirtualItem[] = [];\n\n for (let i = renderStart; i <= renderEnd; i++) {\n items.push({ height: this.heightAt(i), index: i, top: this.scrollOffsets[i] });\n }\n\n this.virtualItems = items;\n this.onChange?.(items, this.totalSize);\n }\n}\n\n// ─── Convenience factory ──────────────────────────────────────────────────────\n\n/**\n * Creates and immediately attaches a `Virtualizer` to the given scroll container.\n *\n * @example\n * ```ts\n * import { createVirtualizer } from '@vielzeug/virtualit';\n *\n * const virt = createVirtualizer(scrollContainerEl, {\n * count: items.length,\n * estimateSize: 36,\n * onChange: (virtualItems, totalSize) => {\n * // update your rendered list\n * },\n * });\n *\n * // Later:\n * virt.destroy();\n *\n * // Or, with the Explicit Resource Management proposal:\n * {\n * using virt = createVirtualizer(scrollContainerEl, { ... });\n * } // virt.destroy() called automatically\n * ```\n */\nexport function createVirtualizer(el: HTMLElement, options: VirtualizerOptions): Virtualizer {\n const v = new Virtualizer(options);\n\n v.attach(el);\n\n return v;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vielzeug/virtualit — Lightweight virtual list / infinite-scroll engine.
|
|
3
|
+
*
|
|
4
|
+
* Framework-agnostic: works with any DOM rendering layer.
|
|
5
|
+
* Uses a `ResizeObserver` to re-measure the scroll container and a
|
|
6
|
+
* `scroll` listener to update the visible window.
|
|
7
|
+
*/
|
|
8
|
+
export interface VirtualItem {
|
|
9
|
+
/** Original item index in the full list */
|
|
10
|
+
index: number;
|
|
11
|
+
/** Pixel offset from the top of the virtual scroll area */
|
|
12
|
+
top: number;
|
|
13
|
+
/** Measured (or estimated) pixel height for this item */
|
|
14
|
+
height: number;
|
|
15
|
+
}
|
|
16
|
+
export interface VirtualizerOptions {
|
|
17
|
+
/** Total number of items. */
|
|
18
|
+
count: number;
|
|
19
|
+
/**
|
|
20
|
+
* Either a fixed row height or a per-index estimator function.
|
|
21
|
+
* Defaults to 36px.
|
|
22
|
+
*/
|
|
23
|
+
estimateSize?: number | ((index: number) => number);
|
|
24
|
+
/**
|
|
25
|
+
* Number of items to render outside the visible viewport on each side.
|
|
26
|
+
* Higher values reduce blank-flash during fast scroll at the cost of more DOM nodes.
|
|
27
|
+
* Defaults to 3.
|
|
28
|
+
*/
|
|
29
|
+
overscan?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Called whenever the visible range changes. Trigger your re-render here.
|
|
32
|
+
*/
|
|
33
|
+
onChange?: (items: VirtualItem[], totalSize: number) => void;
|
|
34
|
+
}
|
|
35
|
+
export interface ScrollToIndexOptions {
|
|
36
|
+
/** 'start' | 'end' | 'center' | 'auto'. Defaults to 'auto'. */
|
|
37
|
+
align?: 'start' | 'end' | 'center' | 'auto';
|
|
38
|
+
/** Scroll behaviour. Defaults to 'auto'. */
|
|
39
|
+
behavior?: ScrollBehavior;
|
|
40
|
+
}
|
|
41
|
+
export declare class Virtualizer {
|
|
42
|
+
private _count;
|
|
43
|
+
private _estimateSizeFn;
|
|
44
|
+
private overscan;
|
|
45
|
+
private onChange;
|
|
46
|
+
private measuredHeights;
|
|
47
|
+
private virtualItems;
|
|
48
|
+
private totalSize;
|
|
49
|
+
private scrollOffsets;
|
|
50
|
+
private containerHeight;
|
|
51
|
+
private scrollTop;
|
|
52
|
+
private prevRenderStart;
|
|
53
|
+
private prevRenderEnd;
|
|
54
|
+
private attachedEl;
|
|
55
|
+
private resizeObserver;
|
|
56
|
+
private scrollHandler;
|
|
57
|
+
private pendingBuild;
|
|
58
|
+
constructor(options: VirtualizerOptions);
|
|
59
|
+
get count(): number;
|
|
60
|
+
/** Setting count automatically rebuilds offsets and triggers a re-render. */
|
|
61
|
+
set count(value: number);
|
|
62
|
+
/**
|
|
63
|
+
* Update the size estimator. Clears all measured heights and re-renders.
|
|
64
|
+
* Useful when switching between row density modes (e.g. compact ↔ comfortable).
|
|
65
|
+
*/
|
|
66
|
+
set estimateSize(fn: number | ((index: number) => number));
|
|
67
|
+
/** Start observing the scroll container. */
|
|
68
|
+
attach(el: HTMLElement): void;
|
|
69
|
+
/** Stop observing and remove all listeners. */
|
|
70
|
+
destroy(): void;
|
|
71
|
+
/** Supports the Explicit Resource Management `using` keyword. */
|
|
72
|
+
[Symbol.dispose](): void;
|
|
73
|
+
/** Returns the currently visible virtual items. */
|
|
74
|
+
getVirtualItems(): VirtualItem[];
|
|
75
|
+
/** Total pixel height of the entire list (set as the spacer height). */
|
|
76
|
+
getTotalSize(): number;
|
|
77
|
+
/**
|
|
78
|
+
* Record a measured height for a rendered item (for variable-height lists).
|
|
79
|
+
*
|
|
80
|
+
* Measurements are batched via microtask — safe to call for every item in a
|
|
81
|
+
* render loop without incurring O(n²) rebuilds.
|
|
82
|
+
*/
|
|
83
|
+
measureElement(index: number, height: number): void;
|
|
84
|
+
/** Programmatically scroll to a specific index. */
|
|
85
|
+
scrollToIndex(index: number, options?: ScrollToIndexOptions): void;
|
|
86
|
+
/** Programmatically scroll to a specific pixel offset. */
|
|
87
|
+
scrollToOffset(offset: number, options?: {
|
|
88
|
+
behavior?: ScrollBehavior;
|
|
89
|
+
}): void;
|
|
90
|
+
/**
|
|
91
|
+
* Invalidate all item measurements. Call after a font load or layout shift
|
|
92
|
+
* that changes item heights.
|
|
93
|
+
*/
|
|
94
|
+
invalidate(): void;
|
|
95
|
+
private teardown;
|
|
96
|
+
private heightAt;
|
|
97
|
+
private offsetAt;
|
|
98
|
+
private buildOffsets;
|
|
99
|
+
private computeVisible;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Creates and immediately attaches a `Virtualizer` to the given scroll container.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* import { createVirtualizer } from '@vielzeug/virtualit';
|
|
107
|
+
*
|
|
108
|
+
* const virt = createVirtualizer(scrollContainerEl, {
|
|
109
|
+
* count: items.length,
|
|
110
|
+
* estimateSize: 36,
|
|
111
|
+
* onChange: (virtualItems, totalSize) => {
|
|
112
|
+
* // update your rendered list
|
|
113
|
+
* },
|
|
114
|
+
* });
|
|
115
|
+
*
|
|
116
|
+
* // Later:
|
|
117
|
+
* virt.destroy();
|
|
118
|
+
*
|
|
119
|
+
* // Or, with the Explicit Resource Management proposal:
|
|
120
|
+
* {
|
|
121
|
+
* using virt = createVirtualizer(scrollContainerEl, { ... });
|
|
122
|
+
* } // virt.destroy() called automatically
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export declare function createVirtualizer(el: HTMLElement, options: VirtualizerOptions): Virtualizer;
|
|
126
|
+
//# sourceMappingURL=virtualit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtualit.d.ts","sourceRoot":"","sources":["../src/virtualit.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,MAAM,WAAW,WAAW;IAC1B,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,2DAA2D;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,kBAAkB;IACjC,6BAA6B;IAC7B,KAAK,EAAE,MAAM,CAAC;IACd;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,CAAC;IACpD;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;OAEG;IACH,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9D;AAED,MAAM,WAAW,oBAAoB;IACnC,+DAA+D;IAC/D,KAAK,CAAC,EAAE,OAAO,GAAG,KAAK,GAAG,QAAQ,GAAG,MAAM,CAAC;IAC5C,4CAA4C;IAC5C,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC3B;AAID,qBAAa,WAAW;IAEtB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,eAAe,CAA4B;IACnD,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,QAAQ,CAAkE;IAGlF,OAAO,CAAC,eAAe,CAAkC;IACzD,OAAO,CAAC,YAAY,CAAqB;IACzC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,aAAa,CAAqC;IAC1D,OAAO,CAAC,eAAe,CAAK;IAC5B,OAAO,CAAC,SAAS,CAAK;IAGtB,OAAO,CAAC,eAAe,CAAM;IAC7B,OAAO,CAAC,aAAa,CAAM;IAG3B,OAAO,CAAC,UAAU,CAA4B;IAC9C,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,aAAa,CAA6B;IAGlD,OAAO,CAAC,YAAY,CAAS;gBAEjB,OAAO,EAAE,kBAAkB;IAevC,IAAI,KAAK,IAAI,MAAM,CAElB;IAED,6EAA6E;IAC7E,IAAI,KAAK,CAAC,KAAK,EAAE,MAAM,EAKtB;IAED;;;OAGG;IACH,IAAI,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,CAAC,EAMxD;IAED,4CAA4C;IAC5C,MAAM,CAAC,EAAE,EAAE,WAAW,GAAG,IAAI;IAwB7B,+CAA+C;IAC/C,OAAO,IAAI,IAAI;IAIf,iEAAiE;IACjE,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,IAAI;IAIxB,mDAAmD;IACnD,eAAe,IAAI,WAAW,EAAE;IAIhC,wEAAwE;IACxE,YAAY,IAAI,MAAM;IAItB;;;;;OAKG;IACH,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAgBnD,mDAAmD;IACnD,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,GAAE,oBAAyB,GAAG,IAAI;IAgCtE,0DAA0D;IAC1D,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE;QAAE,QAAQ,CAAC,EAAE,cAAc,CAAA;KAAO,GAAG,IAAI;IAIjF;;;OAGG;IACH,UAAU,IAAI,IAAI;IASlB,OAAO,CAAC,QAAQ;IAWhB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,QAAQ;IAIhB,OAAO,CAAC,YAAY;IAgBpB,OAAO,CAAC,cAAc;CAiDvB;AAID;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,iBAAiB,CAAC,EAAE,EAAE,WAAW,EAAE,OAAO,EAAE,kBAAkB,GAAG,WAAW,CAM3F"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
//#region src/virtualit.ts
|
|
2
|
+
var e = class {
|
|
3
|
+
_count;
|
|
4
|
+
_estimateSizeFn;
|
|
5
|
+
overscan;
|
|
6
|
+
onChange;
|
|
7
|
+
measuredHeights = /* @__PURE__ */ new Map();
|
|
8
|
+
virtualItems = [];
|
|
9
|
+
totalSize = 0;
|
|
10
|
+
scrollOffsets = new Float64Array();
|
|
11
|
+
containerHeight = 0;
|
|
12
|
+
scrollTop = 0;
|
|
13
|
+
prevRenderStart = -1;
|
|
14
|
+
prevRenderEnd = -1;
|
|
15
|
+
attachedEl = null;
|
|
16
|
+
resizeObserver = null;
|
|
17
|
+
scrollHandler = null;
|
|
18
|
+
pendingBuild = !1;
|
|
19
|
+
constructor(e) {
|
|
20
|
+
this._count = e.count;
|
|
21
|
+
let t = e.estimateSize ?? 36;
|
|
22
|
+
this._estimateSizeFn = typeof t == "number" ? () => t : t, this.overscan = e.overscan ?? 3, this.onChange = e.onChange, this.buildOffsets();
|
|
23
|
+
}
|
|
24
|
+
get count() {
|
|
25
|
+
return this._count;
|
|
26
|
+
}
|
|
27
|
+
set count(e) {
|
|
28
|
+
this._count = e, this.buildOffsets(), this.attachedEl && this.computeVisible();
|
|
29
|
+
}
|
|
30
|
+
set estimateSize(e) {
|
|
31
|
+
this._estimateSizeFn = typeof e == "number" ? () => e : e, this.measuredHeights.clear(), this.buildOffsets(), this.attachedEl && this.computeVisible();
|
|
32
|
+
}
|
|
33
|
+
attach(e) {
|
|
34
|
+
this.teardown(), this.attachedEl = e, this.containerHeight = e.clientHeight, this.scrollTop = e.scrollTop, this.scrollHandler = () => {
|
|
35
|
+
this.scrollTop = e.scrollTop, this.computeVisible();
|
|
36
|
+
}, e.addEventListener("scroll", this.scrollHandler, { passive: !0 }), this.resizeObserver = new ResizeObserver(() => {
|
|
37
|
+
this.containerHeight = e.clientHeight, this.computeVisible();
|
|
38
|
+
}), this.resizeObserver.observe(e), this.computeVisible();
|
|
39
|
+
}
|
|
40
|
+
destroy() {
|
|
41
|
+
this.teardown();
|
|
42
|
+
}
|
|
43
|
+
[Symbol.dispose]() {
|
|
44
|
+
this.destroy();
|
|
45
|
+
}
|
|
46
|
+
getVirtualItems() {
|
|
47
|
+
return this.virtualItems;
|
|
48
|
+
}
|
|
49
|
+
getTotalSize() {
|
|
50
|
+
return this.totalSize;
|
|
51
|
+
}
|
|
52
|
+
measureElement(e, t) {
|
|
53
|
+
this.heightAt(e) !== t && (this.measuredHeights.set(e, t), this.pendingBuild || (this.pendingBuild = !0, queueMicrotask(() => {
|
|
54
|
+
this.pendingBuild = !1, this.buildOffsets(), this.attachedEl && this.computeVisible();
|
|
55
|
+
})));
|
|
56
|
+
}
|
|
57
|
+
scrollToIndex(e, t = {}) {
|
|
58
|
+
let n = this.attachedEl;
|
|
59
|
+
if (!n) return;
|
|
60
|
+
let r = Math.max(0, Math.min(e, this._count - 1)), i = t.align ?? "auto", a = t.behavior ?? "auto", o = this.offsetAt(r), s = this.heightAt(r), c;
|
|
61
|
+
if (i === "start") c = o;
|
|
62
|
+
else if (i === "end") c = o + s - this.containerHeight;
|
|
63
|
+
else if (i === "center") c = o - (this.containerHeight - s) / 2;
|
|
64
|
+
else {
|
|
65
|
+
let e = n.scrollTop, t = e + this.containerHeight;
|
|
66
|
+
if (o >= e && o + s <= t) return;
|
|
67
|
+
c = o < e ? o : o + s - this.containerHeight;
|
|
68
|
+
}
|
|
69
|
+
n.scrollTo({
|
|
70
|
+
behavior: a,
|
|
71
|
+
top: Math.max(0, c)
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
scrollToOffset(e, t = {}) {
|
|
75
|
+
this.attachedEl?.scrollTo({
|
|
76
|
+
behavior: t.behavior ?? "auto",
|
|
77
|
+
top: Math.max(0, e)
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
invalidate() {
|
|
81
|
+
this.measuredHeights.clear(), this.buildOffsets(), this.attachedEl && this.computeVisible();
|
|
82
|
+
}
|
|
83
|
+
teardown() {
|
|
84
|
+
this.scrollHandler && this.attachedEl && (this.attachedEl.removeEventListener("scroll", this.scrollHandler), this.scrollHandler = null), this.resizeObserver?.disconnect(), this.resizeObserver = null, this.attachedEl = null;
|
|
85
|
+
}
|
|
86
|
+
heightAt(e) {
|
|
87
|
+
return this.measuredHeights.get(e) ?? this._estimateSizeFn(e);
|
|
88
|
+
}
|
|
89
|
+
offsetAt(e) {
|
|
90
|
+
return this.scrollOffsets[e] ?? 0;
|
|
91
|
+
}
|
|
92
|
+
buildOffsets() {
|
|
93
|
+
this.prevRenderStart = -1, this.prevRenderEnd = -1;
|
|
94
|
+
let e = new Float64Array(this._count + 1);
|
|
95
|
+
e[0] = 0;
|
|
96
|
+
for (let t = 0; t < this._count; t++) e[t + 1] = e[t] + this.heightAt(t);
|
|
97
|
+
this.scrollOffsets = e, this.totalSize = e[this._count] ?? 0;
|
|
98
|
+
}
|
|
99
|
+
computeVisible() {
|
|
100
|
+
let e = this.scrollTop, t = e + this.containerHeight, n = 0, r = this._count - 1;
|
|
101
|
+
for (; n < r;) {
|
|
102
|
+
let t = n + r >> 1;
|
|
103
|
+
this.scrollOffsets[t + 1] <= e ? n = t + 1 : r = t;
|
|
104
|
+
}
|
|
105
|
+
let i = n, a = i, o = this._count - 1;
|
|
106
|
+
for (; a < o;) {
|
|
107
|
+
let e = a + o + 1 >> 1;
|
|
108
|
+
this.scrollOffsets[e] < t ? a = e : o = e - 1;
|
|
109
|
+
}
|
|
110
|
+
let s = a, c = Math.max(0, i - this.overscan), l = Math.min(this._count - 1, s + this.overscan);
|
|
111
|
+
if (c === this.prevRenderStart && l === this.prevRenderEnd) return;
|
|
112
|
+
this.prevRenderStart = c, this.prevRenderEnd = l;
|
|
113
|
+
let u = [];
|
|
114
|
+
for (let e = c; e <= l; e++) u.push({
|
|
115
|
+
height: this.heightAt(e),
|
|
116
|
+
index: e,
|
|
117
|
+
top: this.scrollOffsets[e]
|
|
118
|
+
});
|
|
119
|
+
this.virtualItems = u, this.onChange?.(u, this.totalSize);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
function t(t, n) {
|
|
123
|
+
let r = new e(n);
|
|
124
|
+
return r.attach(t), r;
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
export { e as Virtualizer, t as createVirtualizer };
|
|
128
|
+
|
|
129
|
+
//# sourceMappingURL=virtualit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"virtualit.js","names":[],"sources":["../src/virtualit.ts"],"sourcesContent":["/**\n * @vielzeug/virtualit — Lightweight virtual list / infinite-scroll engine.\n *\n * Framework-agnostic: works with any DOM rendering layer.\n * Uses a `ResizeObserver` to re-measure the scroll container and a\n * `scroll` listener to update the visible window.\n */\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface VirtualItem {\n /** Original item index in the full list */\n index: number;\n /** Pixel offset from the top of the virtual scroll area */\n top: number;\n /** Measured (or estimated) pixel height for this item */\n height: number;\n}\n\nexport interface VirtualizerOptions {\n /** Total number of items. */\n count: number;\n /**\n * Either a fixed row height or a per-index estimator function.\n * Defaults to 36px.\n */\n estimateSize?: number | ((index: number) => number);\n /**\n * Number of items to render outside the visible viewport on each side.\n * Higher values reduce blank-flash during fast scroll at the cost of more DOM nodes.\n * Defaults to 3.\n */\n overscan?: number;\n /**\n * Called whenever the visible range changes. Trigger your re-render here.\n */\n onChange?: (items: VirtualItem[], totalSize: number) => void;\n}\n\nexport interface ScrollToIndexOptions {\n /** 'start' | 'end' | 'center' | 'auto'. Defaults to 'auto'. */\n align?: 'start' | 'end' | 'center' | 'auto';\n /** Scroll behaviour. Defaults to 'auto'. */\n behavior?: ScrollBehavior;\n}\n\n// ─── Virtualizer ──────────────────────────────────────────────────────────────\n\nexport class Virtualizer {\n // mutable options\n private _count: number;\n private _estimateSizeFn: (index: number) => number;\n private overscan: number;\n private onChange: ((items: VirtualItem[], totalSize: number) => void) | undefined;\n\n // internal state\n private measuredHeights: Map<number, number> = new Map();\n private virtualItems: VirtualItem[] = [];\n private totalSize = 0;\n private scrollOffsets: Float64Array = new Float64Array(0); // prefix-sum cache\n private containerHeight = 0;\n private scrollTop = 0;\n\n // render range cache — reset in buildOffsets() so layout changes always re-render\n private prevRenderStart = -1;\n private prevRenderEnd = -1;\n\n // cleanup handles\n private attachedEl: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private scrollHandler: (() => void) | null = null;\n\n // batching flag for measureElement\n private pendingBuild = false;\n\n constructor(options: VirtualizerOptions) {\n this._count = options.count;\n\n const est = options.estimateSize ?? 36;\n\n this._estimateSizeFn = typeof est === 'number' ? () => est : est;\n this.overscan = options.overscan ?? 3;\n this.onChange = options.onChange;\n // Build the offset table eagerly; computeVisible is deferred to attach()\n // so the first onChange call always has a real containerHeight.\n this.buildOffsets();\n }\n\n // ─── Public API ───────────────────────────────────────────────────────────\n\n get count(): number {\n return this._count;\n }\n\n /** Setting count automatically rebuilds offsets and triggers a re-render. */\n set count(value: number) {\n this._count = value;\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n /**\n * Update the size estimator. Clears all measured heights and re-renders.\n * Useful when switching between row density modes (e.g. compact ↔ comfortable).\n */\n set estimateSize(fn: number | ((index: number) => number)) {\n this._estimateSizeFn = typeof fn === 'number' ? () => fn : fn;\n this.measuredHeights.clear();\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n /** Start observing the scroll container. */\n attach(el: HTMLElement): void {\n this.teardown();\n\n this.attachedEl = el;\n this.containerHeight = el.clientHeight;\n this.scrollTop = el.scrollTop;\n\n this.scrollHandler = () => {\n this.scrollTop = el.scrollTop;\n this.computeVisible();\n };\n el.addEventListener('scroll', this.scrollHandler, { passive: true });\n\n this.resizeObserver = new ResizeObserver(() => {\n this.containerHeight = el.clientHeight;\n // The offset table depends only on item heights, not container height —\n // no need to rebuild it here, only recompute the visible window.\n this.computeVisible();\n });\n this.resizeObserver.observe(el);\n\n this.computeVisible();\n }\n\n /** Stop observing and remove all listeners. */\n destroy(): void {\n this.teardown();\n }\n\n /** Supports the Explicit Resource Management `using` keyword. */\n [Symbol.dispose](): void {\n this.destroy();\n }\n\n /** Returns the currently visible virtual items. */\n getVirtualItems(): VirtualItem[] {\n return this.virtualItems;\n }\n\n /** Total pixel height of the entire list (set as the spacer height). */\n getTotalSize(): number {\n return this.totalSize;\n }\n\n /**\n * Record a measured height for a rendered item (for variable-height lists).\n *\n * Measurements are batched via microtask — safe to call for every item in a\n * render loop without incurring O(n²) rebuilds.\n */\n measureElement(index: number, height: number): void {\n if (this.heightAt(index) === height) return;\n\n this.measuredHeights.set(index, height);\n\n if (!this.pendingBuild) {\n this.pendingBuild = true;\n queueMicrotask(() => {\n this.pendingBuild = false;\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n });\n }\n }\n\n /** Programmatically scroll to a specific index. */\n scrollToIndex(index: number, options: ScrollToIndexOptions = {}): void {\n const el = this.attachedEl;\n\n if (!el) return;\n\n const clampedIndex = Math.max(0, Math.min(index, this._count - 1));\n const align = options.align ?? 'auto';\n const behavior = options.behavior ?? 'auto';\n const itemTop = this.offsetAt(clampedIndex);\n const itemHeight = this.heightAt(clampedIndex);\n\n let targetScrollTop: number;\n\n if (align === 'start') {\n targetScrollTop = itemTop;\n } else if (align === 'end') {\n targetScrollTop = itemTop + itemHeight - this.containerHeight;\n } else if (align === 'center') {\n targetScrollTop = itemTop - (this.containerHeight - itemHeight) / 2;\n } else {\n // auto: scroll only if not already visible\n const visibleStart = el.scrollTop;\n const visibleEnd = visibleStart + this.containerHeight;\n\n if (itemTop >= visibleStart && itemTop + itemHeight <= visibleEnd) return;\n\n targetScrollTop = itemTop < visibleStart ? itemTop : itemTop + itemHeight - this.containerHeight;\n }\n\n el.scrollTo({ behavior, top: Math.max(0, targetScrollTop) });\n }\n\n /** Programmatically scroll to a specific pixel offset. */\n scrollToOffset(offset: number, options: { behavior?: ScrollBehavior } = {}): void {\n this.attachedEl?.scrollTo({ behavior: options.behavior ?? 'auto', top: Math.max(0, offset) });\n }\n\n /**\n * Invalidate all item measurements. Call after a font load or layout shift\n * that changes item heights.\n */\n invalidate(): void {\n this.measuredHeights.clear();\n this.buildOffsets();\n\n if (this.attachedEl) this.computeVisible();\n }\n\n // ─── Private Helpers ──────────────────────────────────────────────────────\n\n private teardown(): void {\n if (this.scrollHandler && this.attachedEl) {\n this.attachedEl.removeEventListener('scroll', this.scrollHandler);\n this.scrollHandler = null;\n }\n\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.attachedEl = null;\n }\n\n private heightAt(index: number): number {\n return this.measuredHeights.get(index) ?? this._estimateSizeFn(index);\n }\n\n private offsetAt(index: number): number {\n return this.scrollOffsets[index] ?? 0;\n }\n\n private buildOffsets(): void {\n // Invalidate the render range cache: item positions may shift even when the\n // visible index range stays the same (e.g. an item above grew taller).\n this.prevRenderStart = -1;\n this.prevRenderEnd = -1;\n\n const offsets = new Float64Array(this._count + 1);\n\n offsets[0] = 0;\n for (let i = 0; i < this._count; i++) {\n offsets[i + 1] = offsets[i] + this.heightAt(i);\n }\n this.scrollOffsets = offsets;\n this.totalSize = offsets[this._count] ?? 0;\n }\n\n private computeVisible(): void {\n const start = this.scrollTop;\n const end = start + this.containerHeight;\n\n // Binary search for the first visible index\n let lo = 0;\n let hi = this._count - 1;\n\n while (lo < hi) {\n const mid = (lo + hi) >> 1;\n\n if (this.scrollOffsets[mid + 1] <= start) lo = mid + 1;\n else hi = mid;\n }\n\n const firstVisible = lo;\n\n // Binary search for the last visible index\n let lo2 = firstVisible;\n let hi2 = this._count - 1;\n\n while (lo2 < hi2) {\n const mid = (lo2 + hi2 + 1) >> 1;\n\n if (this.scrollOffsets[mid] < end) lo2 = mid;\n else hi2 = mid - 1;\n }\n\n const lastVisible = lo2;\n const renderStart = Math.max(0, firstVisible - this.overscan);\n const renderEnd = Math.min(this._count - 1, lastVisible + this.overscan);\n\n // Skip re-render when the range is unchanged (e.g. a sub-pixel scroll that\n // doesn't cross an item boundary). The cache is reset in buildOffsets() so\n // any layout change always produces at least one render.\n if (renderStart === this.prevRenderStart && renderEnd === this.prevRenderEnd) return;\n\n this.prevRenderStart = renderStart;\n this.prevRenderEnd = renderEnd;\n\n const items: VirtualItem[] = [];\n\n for (let i = renderStart; i <= renderEnd; i++) {\n items.push({ height: this.heightAt(i), index: i, top: this.scrollOffsets[i] });\n }\n\n this.virtualItems = items;\n this.onChange?.(items, this.totalSize);\n }\n}\n\n// ─── Convenience factory ──────────────────────────────────────────────────────\n\n/**\n * Creates and immediately attaches a `Virtualizer` to the given scroll container.\n *\n * @example\n * ```ts\n * import { createVirtualizer } from '@vielzeug/virtualit';\n *\n * const virt = createVirtualizer(scrollContainerEl, {\n * count: items.length,\n * estimateSize: 36,\n * onChange: (virtualItems, totalSize) => {\n * // update your rendered list\n * },\n * });\n *\n * // Later:\n * virt.destroy();\n *\n * // Or, with the Explicit Resource Management proposal:\n * {\n * using virt = createVirtualizer(scrollContainerEl, { ... });\n * } // virt.destroy() called automatically\n * ```\n */\nexport function createVirtualizer(el: HTMLElement, options: VirtualizerOptions): Virtualizer {\n const v = new Virtualizer(options);\n\n v.attach(el);\n\n return v;\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vielzeug/virtualit",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"files": [
|
|
6
|
+
"dist"
|
|
7
|
+
],
|
|
8
|
+
"main": "./dist/index.cjs",
|
|
9
|
+
"module": "./dist/index.js",
|
|
10
|
+
"types": "dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"source": "./src/index.ts",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.cjs"
|
|
17
|
+
},
|
|
18
|
+
"./dom": {
|
|
19
|
+
"source": "./src/dom/index.ts",
|
|
20
|
+
"types": "./dist/dom/index.d.ts",
|
|
21
|
+
"import": "./dist/dom.js",
|
|
22
|
+
"require": "./dist/dom.cjs"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "vite build && pnpm run build:types",
|
|
27
|
+
"build:types": "tsc -p tsconfig.declarations.json",
|
|
28
|
+
"fix": "eslint --fix src",
|
|
29
|
+
"lint": "eslint src",
|
|
30
|
+
"prepublishOnly": "pnpm run build",
|
|
31
|
+
"test": "vitest"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^25.5.0",
|
|
40
|
+
"typescript": "~6.0.2",
|
|
41
|
+
"vite": "^8.0.2",
|
|
42
|
+
"vitest": "^4.1.1"
|
|
43
|
+
}
|
|
44
|
+
}
|