@qorejs/qore 0.6.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.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * Qore Virtual List - High-performance list rendering
3
+ * Windowed rendering for large lists
4
+ */
5
+
6
+ import { signal, computed, effect } from './signal';
7
+ import { h, render, VNode, Component, For } from './render';
8
+
9
+ export interface VirtualListItem<T> {
10
+ id: string | number;
11
+ data: T;
12
+ height?: number;
13
+ }
14
+
15
+ export interface VirtualListProps<T> {
16
+ items: T[];
17
+ itemHeight: number | ((item: T, index: number) => number);
18
+ containerHeight: number;
19
+ renderItem: (item: T, index: number) => VNode;
20
+ overscan?: number; // Number of items to render outside visible area
21
+ onScroll?: (scrollTop: number) => void;
22
+ onRangeChange?: (startIndex: number, endIndex: number) => void;
23
+ className?: string;
24
+ getKey?: (item: T, index: number) => string | number;
25
+ }
26
+
27
+ export interface VirtualListState {
28
+ scrollTop: number;
29
+ containerHeight: number;
30
+ totalHeight: number;
31
+ visibleStartIndex: number;
32
+ visibleEndIndex: number;
33
+ }
34
+
35
+ /**
36
+ * Virtual List Component
37
+ * Only renders visible items for better performance
38
+ */
39
+ export function VirtualList<T>(props: VirtualListProps<T>): Component {
40
+ const {
41
+ items,
42
+ itemHeight,
43
+ containerHeight,
44
+ renderItem,
45
+ overscan = 5,
46
+ onScroll,
47
+ onRangeChange,
48
+ className = 'virtual-list',
49
+ getKey = (item, index) => index
50
+ } = props;
51
+
52
+ const isDynamicHeight = typeof itemHeight === 'function';
53
+
54
+ // Scroll position
55
+ const scrollTop = signal(0);
56
+
57
+ // Calculate total height
58
+ const totalHeight = computed(() => {
59
+ if (!isDynamicHeight) {
60
+ return items.length * itemHeight;
61
+ }
62
+
63
+ let height = 0;
64
+ for (let i = 0; i < items.length; i++) {
65
+ height += itemHeight(items[i], i);
66
+ }
67
+ return height;
68
+ });
69
+
70
+ // Get item height at index
71
+ const getItemHeight = (index: number): number => {
72
+ if (!isDynamicHeight) {
73
+ return itemHeight as number;
74
+ }
75
+ return itemHeight(items[index], index);
76
+ };
77
+
78
+ // Calculate item position (offset from top)
79
+ const getItemOffset = (index: number): number => {
80
+ if (!isDynamicHeight) {
81
+ return index * (itemHeight as number);
82
+ }
83
+
84
+ let offset = 0;
85
+ for (let i = 0; i < index; i++) {
86
+ offset += getItemHeight(i);
87
+ }
88
+ return offset;
89
+ };
90
+
91
+ // Find the first visible item index
92
+ const findStartIndex = (scrollPos: number): number => {
93
+ if (!isDynamicHeight) {
94
+ return Math.max(0, Math.floor(scrollPos / (itemHeight as number)));
95
+ }
96
+
97
+ let offset = 0;
98
+ for (let i = 0; i < items.length; i++) {
99
+ const height = getItemHeight(i);
100
+ if (offset + height > scrollPos) {
101
+ return i;
102
+ }
103
+ offset += height;
104
+ }
105
+ return items.length - 1;
106
+ };
107
+
108
+ // Find the last visible item index
109
+ const findEndIndex = (startIndex: number, scrollPos: number): number => {
110
+ const viewportEnd = scrollPos + containerHeight;
111
+
112
+ if (!isDynamicHeight) {
113
+ return Math.min(
114
+ items.length - 1,
115
+ Math.ceil(viewportEnd / (itemHeight as number))
116
+ );
117
+ }
118
+
119
+ let offset = getItemOffset(startIndex);
120
+ for (let i = startIndex; i < items.length; i++) {
121
+ const height = getItemHeight(i);
122
+ if (offset > viewportEnd) {
123
+ return i;
124
+ }
125
+ offset += height;
126
+ }
127
+ return items.length - 1;
128
+ };
129
+
130
+ // Visible range
131
+ const visibleRange = computed(() => {
132
+ const scrollPos = scrollTop();
133
+ let startIndex = findStartIndex(scrollPos);
134
+ let endIndex = findEndIndex(startIndex, scrollPos);
135
+
136
+ // Add overscan
137
+ startIndex = Math.max(0, startIndex - overscan);
138
+ endIndex = Math.min(items.length - 1, endIndex + overscan);
139
+
140
+ return { startIndex, endIndex };
141
+ });
142
+
143
+ // Notify range change
144
+ effect(() => {
145
+ const { startIndex, endIndex } = visibleRange();
146
+ onRangeChange?.(startIndex, endIndex);
147
+ });
148
+
149
+ // Handle scroll
150
+ const handleScroll = (e: Event) => {
151
+ const target = e.target as HTMLElement;
152
+ const newScrollTop = target.scrollTop;
153
+ scrollTop(newScrollTop);
154
+ onScroll?.(newScrollTop);
155
+ };
156
+
157
+ // Render
158
+ return () => {
159
+ const { startIndex, endIndex } = visibleRange();
160
+ const visibleItems = items.slice(startIndex, endIndex + 1);
161
+
162
+ // Calculate spacer heights
163
+ const topSpacerHeight = getItemOffset(startIndex);
164
+ const bottomSpacerHeight = totalHeight() - getItemOffset(endIndex + 1);
165
+
166
+ return h('div', {
167
+ class: className,
168
+ style: {
169
+ height: containerHeight + 'px',
170
+ overflowY: 'auto',
171
+ position: 'relative'
172
+ },
173
+ onscroll: handleScroll
174
+ }, [
175
+ // Top spacer
176
+ h('div', {
177
+ style: {
178
+ height: topSpacerHeight + 'px',
179
+ flexShrink: 0
180
+ }
181
+ }),
182
+
183
+ // Visible items
184
+ ...visibleItems.map((item, i) => {
185
+ const index = startIndex + i;
186
+ const key = getKey(item, index);
187
+ const height = getItemHeight(index);
188
+
189
+ return h('div', {
190
+ key: key,
191
+ 'data-index': index,
192
+ style: {
193
+ height: height + 'px',
194
+ flexShrink: 0
195
+ }
196
+ }, [
197
+ renderItem(item, index)
198
+ ]);
199
+ }),
200
+
201
+ // Bottom spacer
202
+ h('div', {
203
+ style: {
204
+ height: Math.max(0, bottomSpacerHeight) + 'px',
205
+ flexShrink: 0
206
+ }
207
+ })
208
+ ]);
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Infinite Scroll Virtual List
214
+ * Supports loading more items as user scrolls
215
+ */
216
+ export interface InfiniteListProps<T> extends VirtualListProps<T> {
217
+ onLoadMore: () => Promise<T[]>;
218
+ hasMore: boolean;
219
+ loading?: boolean;
220
+ loadingComponent?: VNode;
221
+ endComponent?: VNode;
222
+ }
223
+
224
+ export function InfiniteList<T>(props: InfiniteListProps<T>): Component {
225
+ const {
226
+ items,
227
+ itemHeight,
228
+ containerHeight,
229
+ renderItem,
230
+ onLoadMore,
231
+ hasMore,
232
+ loading = false,
233
+ loadingComponent = h('div', { class: 'loading' }, 'Loading...'),
234
+ endComponent = h('div', { class: 'end' }, 'No more items'),
235
+ ...listProps
236
+ } = props;
237
+
238
+ const isLoading = signal(loading);
239
+ const nearEnd = signal(false);
240
+
241
+ // Load more when near end
242
+ effect(() => {
243
+ if (nearEnd() && hasMore && !isLoading()) {
244
+ isLoading(true);
245
+ onLoadMore().finally(() => {
246
+ isLoading(false);
247
+ });
248
+ }
249
+ });
250
+
251
+ return () => {
252
+ const allItems = [...items];
253
+
254
+ // Add loading indicator
255
+ if (isLoading()) {
256
+ allItems.push({ __loading: true } as any);
257
+ }
258
+
259
+ // Add end message
260
+ if (!hasMore && !isLoading()) {
261
+ allItems.push({ __end: true } as any);
262
+ }
263
+
264
+ const augmentedRenderItem = (item: any, index: number): VNode => {
265
+ if ((item as any).__loading) {
266
+ return loadingComponent;
267
+ }
268
+ if ((item as any).__end) {
269
+ return endComponent;
270
+ }
271
+ return renderItem(item, index);
272
+ };
273
+
274
+ return h(VirtualList, {
275
+ ...listProps,
276
+ items: allItems,
277
+ itemHeight,
278
+ containerHeight,
279
+ renderItem: augmentedRenderItem,
280
+ onRangeChange: (start, end) => {
281
+ // Trigger load more when near end
282
+ const threshold = Math.floor(allItems.length * 0.8);
283
+ nearEnd(end >= threshold);
284
+ listProps.onRangeChange?.(start, end);
285
+ }
286
+ });
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Fixed-height virtual list (optimized for uniform item heights)
292
+ */
293
+ export function FixedVirtualList<T>(props: Omit<VirtualListProps<T>, 'itemHeight'> & {
294
+ itemHeight: number;
295
+ }): Component {
296
+ return VirtualList(props);
297
+ }
298
+
299
+ /**
300
+ * Grid-based virtual list for 2D layouts
301
+ */
302
+ export interface VirtualGridProps<T> {
303
+ items: T[];
304
+ itemWidth: number;
305
+ itemHeight: number;
306
+ containerWidth: number;
307
+ containerHeight: number;
308
+ renderItem: (item: T, index: number) => VNode;
309
+ gap?: number;
310
+ overscan?: number;
311
+ className?: string;
312
+ }
313
+
314
+ export function VirtualGrid<T>(props: VirtualGridProps<T>): Component {
315
+ const {
316
+ items,
317
+ itemWidth,
318
+ itemHeight,
319
+ containerWidth,
320
+ containerHeight,
321
+ renderItem,
322
+ gap = 0,
323
+ overscan = 2,
324
+ className = 'virtual-grid'
325
+ } = props;
326
+
327
+ const columns = Math.floor(containerWidth / (itemWidth + gap));
328
+ const rows = Math.ceil(items.length / columns);
329
+
330
+ const scrollTop = signal(0);
331
+
332
+ const visibleRows = Math.ceil(containerHeight / itemHeight);
333
+
334
+ const visibleRange = computed(() => {
335
+ const scrollPos = scrollTop();
336
+ const startRow = Math.max(0, Math.floor(scrollPos / itemHeight) - overscan);
337
+ const endRow = Math.min(rows - 1, Math.ceil((scrollPos + containerHeight) / itemHeight) + overscan);
338
+
339
+ return {
340
+ startRow,
341
+ endRow,
342
+ startIndex: startRow * columns,
343
+ endIndex: Math.min(items.length - 1, (endRow + 1) * columns - 1)
344
+ };
345
+ });
346
+
347
+ const handleScroll = (e: Event) => {
348
+ const target = e.target as HTMLElement;
349
+ scrollTop(target.scrollTop);
350
+ };
351
+
352
+ return () => {
353
+ const { startRow, startIndex, endIndex } = visibleRange();
354
+ const visibleItems = items.slice(startIndex, endIndex + 1);
355
+
356
+ const topSpacerHeight = startRow * itemHeight;
357
+ const bottomSpacerHeight = (rows - startRow - Math.ceil(visibleItems.length / columns)) * itemHeight;
358
+
359
+ return h('div', {
360
+ class: className,
361
+ style: {
362
+ width: containerWidth + 'px',
363
+ height: containerHeight + 'px',
364
+ overflowY: 'auto',
365
+ position: 'relative'
366
+ },
367
+ onscroll: handleScroll
368
+ }, [
369
+ h('div', {
370
+ style: {
371
+ height: topSpacerHeight + 'px'
372
+ }
373
+ }),
374
+
375
+ h('div', {
376
+ style: {
377
+ display: 'flex',
378
+ flexWrap: 'wrap',
379
+ gap: gap + 'px'
380
+ }
381
+ }, [
382
+ ...visibleItems.map((item, i) => {
383
+ const index = startIndex + i;
384
+ return h('div', {
385
+ key: index,
386
+ 'data-index': index,
387
+ style: {
388
+ width: itemWidth + 'px',
389
+ height: itemHeight + 'px',
390
+ flexShrink: 0
391
+ }
392
+ }, [
393
+ renderItem(item, index)
394
+ ]);
395
+ })
396
+ ]),
397
+
398
+ h('div', {
399
+ style: {
400
+ height: Math.max(0, bottomSpacerHeight) + 'px'
401
+ }
402
+ })
403
+ ]);
404
+ };
405
+ }