@runtypelabs/persona 1.41.0 → 1.43.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 +55 -1
- package/dist/index.cjs +30 -30
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +307 -1
- package/dist/index.d.ts +307 -1
- package/dist/index.global.js +71 -71
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +30 -30
- package/dist/index.js.map +1 -1
- package/dist/widget.css +185 -0
- package/package.json +2 -1
- package/src/client.test.ts +645 -0
- package/src/client.ts +373 -0
- package/src/components/event-stream-view.test.ts +1233 -0
- package/src/components/event-stream-view.ts +1179 -0
- package/src/index.ts +19 -1
- package/src/plugins/types.ts +34 -1
- package/src/runtime/init.ts +5 -0
- package/src/session.ts +104 -1
- package/src/styles/widget.css +185 -0
- package/src/types.ts +252 -0
- package/src/ui.ts +281 -4
- package/src/utils/event-stream-buffer.test.ts +268 -0
- package/src/utils/event-stream-buffer.ts +112 -0
- package/src/utils/event-stream-capture.test.ts +539 -0
- package/src/utils/event-stream-controller.test.ts +445 -0
- package/src/utils/event-stream-store.test.ts +181 -0
- package/src/utils/event-stream-store.ts +182 -0
- package/src/utils/virtual-scroller.test.ts +449 -0
- package/src/utils/virtual-scroller.ts +151 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { SSEEventRecord } from "../types";
|
|
2
|
+
|
|
3
|
+
export class EventStreamStore {
|
|
4
|
+
private db: IDBDatabase | null = null;
|
|
5
|
+
private pendingWrites: SSEEventRecord[] = [];
|
|
6
|
+
private flushScheduled = false;
|
|
7
|
+
private isDestroyed = false;
|
|
8
|
+
private readonly dbName: string;
|
|
9
|
+
private readonly storeName: string;
|
|
10
|
+
|
|
11
|
+
constructor(dbName = "persona-event-stream", storeName = "events") {
|
|
12
|
+
this.dbName = dbName;
|
|
13
|
+
this.storeName = storeName;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
open(): Promise<void> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
try {
|
|
19
|
+
const request = indexedDB.open(this.dbName, 1);
|
|
20
|
+
|
|
21
|
+
request.onupgradeneeded = () => {
|
|
22
|
+
const db = request.result;
|
|
23
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
24
|
+
const store = db.createObjectStore(this.storeName, { keyPath: "id" });
|
|
25
|
+
store.createIndex("timestamp", "timestamp", { unique: false });
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
request.onsuccess = () => {
|
|
30
|
+
this.db = request.result;
|
|
31
|
+
resolve();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
request.onerror = () => {
|
|
35
|
+
reject(request.error);
|
|
36
|
+
};
|
|
37
|
+
} catch (err) {
|
|
38
|
+
reject(err);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
put(event: SSEEventRecord): void {
|
|
44
|
+
if (!this.db || this.isDestroyed) return;
|
|
45
|
+
this.pendingWrites.push(event);
|
|
46
|
+
if (!this.flushScheduled) {
|
|
47
|
+
this.flushScheduled = true;
|
|
48
|
+
queueMicrotask(() => this.flushWrites());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
putBatch(events: SSEEventRecord[]): void {
|
|
53
|
+
if (!this.db || this.isDestroyed || events.length === 0) return;
|
|
54
|
+
try {
|
|
55
|
+
const tx = this.db.transaction(this.storeName, "readwrite");
|
|
56
|
+
const store = tx.objectStore(this.storeName);
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
store.put(event);
|
|
59
|
+
}
|
|
60
|
+
} catch {
|
|
61
|
+
// Silently fail - IndexedDB writes are best-effort
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getAll(): Promise<SSEEventRecord[]> {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
if (!this.db) {
|
|
68
|
+
resolve([]);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const tx = this.db.transaction(this.storeName, "readonly");
|
|
73
|
+
const store = tx.objectStore(this.storeName);
|
|
74
|
+
const index = store.index("timestamp");
|
|
75
|
+
const request = index.getAll();
|
|
76
|
+
|
|
77
|
+
request.onsuccess = () => {
|
|
78
|
+
resolve(request.result as SSEEventRecord[]);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
request.onerror = () => {
|
|
82
|
+
reject(request.error);
|
|
83
|
+
};
|
|
84
|
+
} catch (err) {
|
|
85
|
+
reject(err);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getCount(): Promise<number> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
if (!this.db) {
|
|
93
|
+
resolve(0);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const tx = this.db.transaction(this.storeName, "readonly");
|
|
98
|
+
const store = tx.objectStore(this.storeName);
|
|
99
|
+
const request = store.count();
|
|
100
|
+
|
|
101
|
+
request.onsuccess = () => {
|
|
102
|
+
resolve(request.result);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
request.onerror = () => {
|
|
106
|
+
reject(request.error);
|
|
107
|
+
};
|
|
108
|
+
} catch (err) {
|
|
109
|
+
reject(err);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
clear(): Promise<void> {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
if (!this.db) {
|
|
117
|
+
resolve();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.pendingWrites = [];
|
|
121
|
+
try {
|
|
122
|
+
const tx = this.db.transaction(this.storeName, "readwrite");
|
|
123
|
+
const store = tx.objectStore(this.storeName);
|
|
124
|
+
const request = store.clear();
|
|
125
|
+
|
|
126
|
+
request.onsuccess = () => {
|
|
127
|
+
resolve();
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
request.onerror = () => {
|
|
131
|
+
reject(request.error);
|
|
132
|
+
};
|
|
133
|
+
} catch (err) {
|
|
134
|
+
reject(err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
close(): void {
|
|
140
|
+
if (this.db) {
|
|
141
|
+
this.db.close();
|
|
142
|
+
this.db = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
destroy(): Promise<void> {
|
|
147
|
+
this.isDestroyed = true;
|
|
148
|
+
this.pendingWrites = [];
|
|
149
|
+
this.close();
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
try {
|
|
152
|
+
const request = indexedDB.deleteDatabase(this.dbName);
|
|
153
|
+
|
|
154
|
+
request.onsuccess = () => {
|
|
155
|
+
resolve();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
request.onerror = () => {
|
|
159
|
+
reject(request.error);
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
reject(err);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private flushWrites(): void {
|
|
168
|
+
this.flushScheduled = false;
|
|
169
|
+
if (!this.db || this.isDestroyed || this.pendingWrites.length === 0) return;
|
|
170
|
+
const toWrite = this.pendingWrites;
|
|
171
|
+
this.pendingWrites = [];
|
|
172
|
+
try {
|
|
173
|
+
const tx = this.db.transaction(this.storeName, "readwrite");
|
|
174
|
+
const store = tx.objectStore(this.storeName);
|
|
175
|
+
for (const event of toWrite) {
|
|
176
|
+
store.put(event);
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Silently fail - IndexedDB writes are best-effort
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { VirtualScroller } from "./virtual-scroller";
|
|
3
|
+
|
|
4
|
+
// Minimal DOM mock for Node environment
|
|
5
|
+
function createMockElement(tag = "div"): any {
|
|
6
|
+
const children: any[] = [];
|
|
7
|
+
const style: Record<string, string> = {};
|
|
8
|
+
const listeners: Record<string, Function[]> = {};
|
|
9
|
+
const el: any = {
|
|
10
|
+
tagName: tag.toUpperCase(),
|
|
11
|
+
style,
|
|
12
|
+
children,
|
|
13
|
+
childNodes: children,
|
|
14
|
+
firstChild: null,
|
|
15
|
+
parentNode: null,
|
|
16
|
+
appendChild(child: any) {
|
|
17
|
+
children.push(child);
|
|
18
|
+
child.parentNode = el;
|
|
19
|
+
el.firstChild = children[0] || null;
|
|
20
|
+
return child;
|
|
21
|
+
},
|
|
22
|
+
remove() {
|
|
23
|
+
if (el.parentNode) {
|
|
24
|
+
const idx = el.parentNode.children.indexOf(el);
|
|
25
|
+
if (idx >= 0) el.parentNode.children.splice(idx, 1);
|
|
26
|
+
el.parentNode = null;
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
addEventListener(event: string, handler: Function) {
|
|
30
|
+
if (!listeners[event]) listeners[event] = [];
|
|
31
|
+
listeners[event].push(handler);
|
|
32
|
+
},
|
|
33
|
+
removeEventListener(event: string, handler: Function) {
|
|
34
|
+
if (listeners[event]) {
|
|
35
|
+
listeners[event] = listeners[event].filter((h) => h !== handler);
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
setAttribute(name: string, value: string) {
|
|
39
|
+
el[`__attr_${name}`] = value;
|
|
40
|
+
},
|
|
41
|
+
textContent: "",
|
|
42
|
+
innerHTML: "",
|
|
43
|
+
offsetHeight: 0,
|
|
44
|
+
scrollTop: 0,
|
|
45
|
+
scrollHeight: 200,
|
|
46
|
+
clientHeight: 200,
|
|
47
|
+
scrollTo(opts: { top: number; behavior?: string }) {
|
|
48
|
+
el.scrollTop = opts.top;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
return el;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Stub document.createElement for VirtualScroller internals
|
|
55
|
+
const origCreateElement = globalThis.document?.createElement;
|
|
56
|
+
let rafCallbacks: Function[] = [];
|
|
57
|
+
|
|
58
|
+
function _flushRAF() {
|
|
59
|
+
const cbs = rafCallbacks.slice();
|
|
60
|
+
rafCallbacks = [];
|
|
61
|
+
cbs.forEach((cb) => cb());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
rafCallbacks = [];
|
|
66
|
+
// Provide a minimal document.createElement
|
|
67
|
+
if (!globalThis.document) {
|
|
68
|
+
(globalThis as any).document = {};
|
|
69
|
+
}
|
|
70
|
+
(globalThis.document as any).createElement = (tag: string) =>
|
|
71
|
+
createMockElement(tag);
|
|
72
|
+
// Queue RAF callbacks so we can control when they execute
|
|
73
|
+
(globalThis as any).requestAnimationFrame = (cb: Function) => {
|
|
74
|
+
rafCallbacks.push(cb);
|
|
75
|
+
return rafCallbacks.length;
|
|
76
|
+
};
|
|
77
|
+
(globalThis as any).cancelAnimationFrame = (id: number) => {
|
|
78
|
+
if (id > 0 && id <= rafCallbacks.length) {
|
|
79
|
+
rafCallbacks[id - 1] = () => {};
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
if (origCreateElement) {
|
|
86
|
+
globalThis.document.createElement = origCreateElement;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("VirtualScroller", () => {
|
|
91
|
+
let container: any;
|
|
92
|
+
let scroller: VirtualScroller;
|
|
93
|
+
|
|
94
|
+
beforeEach(() => {
|
|
95
|
+
container = createMockElement("div");
|
|
96
|
+
container.clientHeight = 200;
|
|
97
|
+
container.scrollHeight = 200;
|
|
98
|
+
container.scrollTop = 0;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(() => {
|
|
102
|
+
scroller?.destroy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
function createRenderRow() {
|
|
106
|
+
return (index: number) => {
|
|
107
|
+
const el = createMockElement("div");
|
|
108
|
+
el.textContent = `Row ${index}`;
|
|
109
|
+
el.__dataIndex = index;
|
|
110
|
+
return el;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
it("should create spacer and viewport elements in container", () => {
|
|
115
|
+
scroller = new VirtualScroller({
|
|
116
|
+
container,
|
|
117
|
+
rowHeight: 40,
|
|
118
|
+
renderRow: createRenderRow(),
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Spacer should be in container
|
|
122
|
+
expect(container.children.length).toBe(1);
|
|
123
|
+
const spacer = container.children[0];
|
|
124
|
+
expect(spacer.style.position).toBe("relative");
|
|
125
|
+
expect(spacer.style.width).toBe("100%");
|
|
126
|
+
|
|
127
|
+
// Viewport should be in spacer
|
|
128
|
+
expect(spacer.children.length).toBe(1);
|
|
129
|
+
const viewport = spacer.children[0];
|
|
130
|
+
expect(viewport.style.position).toBe("absolute");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("should update spacer height when totalCount changes", () => {
|
|
134
|
+
scroller = new VirtualScroller({
|
|
135
|
+
container,
|
|
136
|
+
rowHeight: 40,
|
|
137
|
+
renderRow: createRenderRow(),
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
scroller.setTotalCount(100);
|
|
141
|
+
const spacer = container.children[0];
|
|
142
|
+
expect(spacer.style.height).toBe("4000px");
|
|
143
|
+
|
|
144
|
+
scroller.setTotalCount(50);
|
|
145
|
+
expect(spacer.style.height).toBe("2000px");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("should render visible rows on setTotalCount", () => {
|
|
149
|
+
scroller = new VirtualScroller({
|
|
150
|
+
container,
|
|
151
|
+
rowHeight: 40,
|
|
152
|
+
overscan: 0,
|
|
153
|
+
renderRow: createRenderRow(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
scroller.setTotalCount(100);
|
|
157
|
+
|
|
158
|
+
// Container height 200 / rowHeight 40 = ceil(5) visible rows
|
|
159
|
+
const viewport = container.children[0].children[0];
|
|
160
|
+
expect(viewport.children.length).toBe(5);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("should render rows with correct positioning", () => {
|
|
164
|
+
scroller = new VirtualScroller({
|
|
165
|
+
container,
|
|
166
|
+
rowHeight: 40,
|
|
167
|
+
overscan: 0,
|
|
168
|
+
renderRow: createRenderRow(),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
scroller.setTotalCount(100);
|
|
172
|
+
|
|
173
|
+
const viewport = container.children[0].children[0];
|
|
174
|
+
const firstRow = viewport.children[0];
|
|
175
|
+
|
|
176
|
+
expect(firstRow.style.position).toBe("absolute");
|
|
177
|
+
expect(firstRow.style.height).toBe("40px");
|
|
178
|
+
expect(firstRow.style.transform).toBe("translateY(0px)");
|
|
179
|
+
expect(firstRow.textContent).toBe("Row 0");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("should include overscan rows", () => {
|
|
183
|
+
scroller = new VirtualScroller({
|
|
184
|
+
container,
|
|
185
|
+
rowHeight: 40,
|
|
186
|
+
overscan: 3,
|
|
187
|
+
renderRow: createRenderRow(),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
scroller.setTotalCount(100);
|
|
191
|
+
|
|
192
|
+
// 5 visible + 3 overscan below = 8 (no overscan above at scrollTop=0)
|
|
193
|
+
const viewport = container.children[0].children[0];
|
|
194
|
+
expect(viewport.children.length).toBe(8);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should clear rows when totalCount is 0", () => {
|
|
198
|
+
scroller = new VirtualScroller({
|
|
199
|
+
container,
|
|
200
|
+
rowHeight: 40,
|
|
201
|
+
overscan: 0,
|
|
202
|
+
renderRow: createRenderRow(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
scroller.setTotalCount(10);
|
|
206
|
+
const viewport = container.children[0].children[0];
|
|
207
|
+
expect(viewport.children.length).toBeGreaterThan(0);
|
|
208
|
+
|
|
209
|
+
scroller.setTotalCount(0);
|
|
210
|
+
expect(viewport.children.length).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("should use default rowHeight of 40 and overscan of 5", () => {
|
|
214
|
+
scroller = new VirtualScroller({
|
|
215
|
+
container,
|
|
216
|
+
renderRow: createRenderRow(),
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
scroller.setTotalCount(100);
|
|
220
|
+
const spacer = container.children[0];
|
|
221
|
+
expect(spacer.style.height).toBe("4000px");
|
|
222
|
+
// ceil(200/40) + 5 = 10
|
|
223
|
+
const viewport = spacer.children[0];
|
|
224
|
+
expect(viewport.children.length).toBe(10);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should report isNearBottom correctly", () => {
|
|
228
|
+
scroller = new VirtualScroller({
|
|
229
|
+
container,
|
|
230
|
+
rowHeight: 40,
|
|
231
|
+
renderRow: createRenderRow(),
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
scroller.setTotalCount(100);
|
|
235
|
+
|
|
236
|
+
// scrollHeight=200, scrollTop=0, clientHeight=200 => distance=0 < 50
|
|
237
|
+
expect(scroller.isNearBottom()).toBe(true);
|
|
238
|
+
|
|
239
|
+
// Simulate scrolling up
|
|
240
|
+
container.scrollHeight = 4000;
|
|
241
|
+
container.scrollTop = 0;
|
|
242
|
+
expect(scroller.isNearBottom()).toBe(false);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("should clean up on destroy", () => {
|
|
246
|
+
scroller = new VirtualScroller({
|
|
247
|
+
container,
|
|
248
|
+
rowHeight: 40,
|
|
249
|
+
overscan: 0,
|
|
250
|
+
renderRow: createRenderRow(),
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
scroller.setTotalCount(10);
|
|
254
|
+
scroller.destroy();
|
|
255
|
+
|
|
256
|
+
// Spacer should be removed from container
|
|
257
|
+
expect(container.children.length).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("should not render more rows than totalCount", () => {
|
|
261
|
+
scroller = new VirtualScroller({
|
|
262
|
+
container,
|
|
263
|
+
rowHeight: 40,
|
|
264
|
+
overscan: 5,
|
|
265
|
+
renderRow: createRenderRow(),
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
// Only 3 items total - should render at most 3
|
|
269
|
+
scroller.setTotalCount(3);
|
|
270
|
+
const viewport = container.children[0].children[0];
|
|
271
|
+
expect(viewport.children.length).toBe(3);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("should handle scrollToBottom", () => {
|
|
275
|
+
scroller = new VirtualScroller({
|
|
276
|
+
container,
|
|
277
|
+
rowHeight: 40,
|
|
278
|
+
renderRow: createRenderRow(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
scroller.setTotalCount(100);
|
|
282
|
+
|
|
283
|
+
// Set offsetHeight on spacer for scrollToBottom calculation
|
|
284
|
+
container.children[0].offsetHeight = 4000;
|
|
285
|
+
|
|
286
|
+
scroller.scrollToBottom();
|
|
287
|
+
expect(scroller.getIsAutoScrolling()).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should not create duplicate rows on repeated setTotalCount", () => {
|
|
291
|
+
scroller = new VirtualScroller({
|
|
292
|
+
container,
|
|
293
|
+
rowHeight: 40,
|
|
294
|
+
overscan: 0,
|
|
295
|
+
renderRow: createRenderRow(),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
scroller.setTotalCount(10);
|
|
299
|
+
const viewport = container.children[0].children[0];
|
|
300
|
+
const countBefore = viewport.children.length;
|
|
301
|
+
|
|
302
|
+
// Call again with same count - should not duplicate
|
|
303
|
+
scroller.setTotalCount(10);
|
|
304
|
+
expect(viewport.children.length).toBe(countBefore);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should remove rows outside visible range when scrolled", () => {
|
|
308
|
+
scroller = new VirtualScroller({
|
|
309
|
+
container,
|
|
310
|
+
rowHeight: 40,
|
|
311
|
+
overscan: 0,
|
|
312
|
+
renderRow: createRenderRow(),
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
scroller.setTotalCount(100);
|
|
316
|
+
const viewport = container.children[0].children[0];
|
|
317
|
+
|
|
318
|
+
// Initially: rows 0-4 (5 visible)
|
|
319
|
+
expect(viewport.children.length).toBe(5);
|
|
320
|
+
expect(viewport.children[0].__dataIndex).toBe(0);
|
|
321
|
+
|
|
322
|
+
// Simulate scroll to show rows 50-54
|
|
323
|
+
container.scrollTop = 2000; // 2000/40 = row 50
|
|
324
|
+
scroller.render();
|
|
325
|
+
|
|
326
|
+
// Should only have rows 50-54
|
|
327
|
+
expect(viewport.children.length).toBe(5);
|
|
328
|
+
expect(viewport.children[0].__dataIndex).toBe(50);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe("with 400px container height", () => {
|
|
332
|
+
beforeEach(() => {
|
|
333
|
+
container.clientHeight = 400;
|
|
334
|
+
container.scrollHeight = 400;
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("should render ~10 visible rows plus overscan with 100 items", () => {
|
|
338
|
+
scroller = new VirtualScroller({
|
|
339
|
+
container,
|
|
340
|
+
rowHeight: 40,
|
|
341
|
+
overscan: 5,
|
|
342
|
+
renderRow: createRenderRow(),
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
scroller.setTotalCount(100);
|
|
346
|
+
|
|
347
|
+
// 400px / 40px = 10 visible + 5 overscan below = 15 rows
|
|
348
|
+
const viewport = container.children[0].children[0];
|
|
349
|
+
expect(viewport.children.length).toBe(15);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should render correct rows when scrollTop is 200", () => {
|
|
353
|
+
scroller = new VirtualScroller({
|
|
354
|
+
container,
|
|
355
|
+
rowHeight: 40,
|
|
356
|
+
overscan: 0,
|
|
357
|
+
renderRow: createRenderRow(),
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
scroller.setTotalCount(100);
|
|
361
|
+
const viewport = container.children[0].children[0];
|
|
362
|
+
|
|
363
|
+
// Initially at scrollTop=0: rows 0-9 (400/40 = 10 visible)
|
|
364
|
+
expect(viewport.children.length).toBe(10);
|
|
365
|
+
|
|
366
|
+
// Scroll to 200px -> first visible row = floor(200/40) = 5
|
|
367
|
+
// Last visible row = ceil((200+400)/40) = 15
|
|
368
|
+
container.scrollTop = 200;
|
|
369
|
+
scroller.render();
|
|
370
|
+
|
|
371
|
+
expect(viewport.children.length).toBe(10);
|
|
372
|
+
expect(viewport.children[0].__dataIndex).toBe(5);
|
|
373
|
+
expect(viewport.children[viewport.children.length - 1].__dataIndex).toBe(14);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should render correct range with overscan when scrollTop is 200", () => {
|
|
377
|
+
scroller = new VirtualScroller({
|
|
378
|
+
container,
|
|
379
|
+
rowHeight: 40,
|
|
380
|
+
overscan: 3,
|
|
381
|
+
renderRow: createRenderRow(),
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
scroller.setTotalCount(100);
|
|
385
|
+
const viewport = container.children[0].children[0];
|
|
386
|
+
|
|
387
|
+
container.scrollTop = 200;
|
|
388
|
+
scroller.render();
|
|
389
|
+
|
|
390
|
+
// startIndex = max(0, floor(200/40) - 3) = max(0, 5-3) = 2
|
|
391
|
+
// endIndex = min(100, ceil((200+400)/40) + 3) = min(100, 15+3) = 18
|
|
392
|
+
// 18 - 2 = 16 rows
|
|
393
|
+
expect(viewport.children.length).toBe(16);
|
|
394
|
+
expect(viewport.children[0].__dataIndex).toBe(2);
|
|
395
|
+
expect(viewport.children[viewport.children.length - 1].__dataIndex).toBe(17);
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("should remove excess rows when totalCount decreases", () => {
|
|
400
|
+
scroller = new VirtualScroller({
|
|
401
|
+
container,
|
|
402
|
+
rowHeight: 40,
|
|
403
|
+
overscan: 0,
|
|
404
|
+
renderRow: createRenderRow(),
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
// Start with many items
|
|
408
|
+
scroller.setTotalCount(100);
|
|
409
|
+
const viewport = container.children[0].children[0];
|
|
410
|
+
const spacer = container.children[0];
|
|
411
|
+
expect(viewport.children.length).toBe(5); // 200/40 = 5
|
|
412
|
+
|
|
413
|
+
// Decrease to 2 items
|
|
414
|
+
scroller.setTotalCount(2);
|
|
415
|
+
expect(spacer.style.height).toBe("80px"); // 2 * 40
|
|
416
|
+
expect(viewport.children.length).toBe(2); // Only 2 rows exist
|
|
417
|
+
expect(viewport.children[0].__dataIndex).toBe(0);
|
|
418
|
+
expect(viewport.children[1].__dataIndex).toBe(1);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("isNearBottom should return true when at bottom", () => {
|
|
422
|
+
scroller = new VirtualScroller({
|
|
423
|
+
container,
|
|
424
|
+
rowHeight: 40,
|
|
425
|
+
renderRow: createRenderRow(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
scroller.setTotalCount(100);
|
|
429
|
+
|
|
430
|
+
// At bottom: scrollHeight - scrollTop - clientHeight < threshold
|
|
431
|
+
container.scrollHeight = 4000;
|
|
432
|
+
container.scrollTop = 3800; // 4000 - 3800 - 200 = 0 < 50
|
|
433
|
+
expect(scroller.isNearBottom()).toBe(true);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("isNearBottom should return false when scrolled up", () => {
|
|
437
|
+
scroller = new VirtualScroller({
|
|
438
|
+
container,
|
|
439
|
+
rowHeight: 40,
|
|
440
|
+
renderRow: createRenderRow(),
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
scroller.setTotalCount(100);
|
|
444
|
+
|
|
445
|
+
container.scrollHeight = 4000;
|
|
446
|
+
container.scrollTop = 1000; // 4000 - 1000 - 200 = 2800 > 50
|
|
447
|
+
expect(scroller.isNearBottom()).toBe(false);
|
|
448
|
+
});
|
|
449
|
+
});
|