@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 ADDED
@@ -0,0 +1,199 @@
1
+ # @vielzeug/virtualit
2
+
3
+ > Lightweight, framework-agnostic virtual list engine for DOM rendering layers
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@vielzeug/virtualit)](https://www.npmjs.com/package/@vielzeug/virtualit) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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.
@@ -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"}
@@ -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,2 @@
1
+ export * from './dom';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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
@@ -0,0 +1,3 @@
1
+ import { Virtualizer as e, createVirtualizer as t } from "./virtualit.js";
2
+ import { createDomVirtualList as n } from "./dom/dom.js";
3
+ export { e as Virtualizer, n as createDomVirtualList, t as createVirtualizer };
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;
@@ -0,0 +1,2 @@
1
+ export * from './virtualit';
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ import { Virtualizer as e, createVirtualizer as t } from "./virtualit.js";
2
+ export { e as Virtualizer, t as createVirtualizer };
@@ -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
+ }