| 68 | }; |
| 69 | |
| 70 | export class VirtualScroller<T> { |
| 71 | private container: HTMLElement; |
| 72 | private scrollContainer: HTMLElement; |
| 73 | private contentContainer: HTMLElement; |
| 74 | private spacer: HTMLElement; |
| 75 | |
| 76 | private items: T[] = []; |
| 77 | private estimatedHeight: number; // Default estimated height for unmeasured items |
| 78 | private overscan: number; |
| 79 | private renderItem: (item: T, index: number) => HTMLElement; |
| 80 | private getItemKey: (item: T, index: number) => string; |
| 81 | |
| 82 | private state: VirtualScrollState = { |
| 83 | startIndex: 0, |
| 84 | endIndex: 0, |
| 85 | totalItems: 0, |
| 86 | offsetY: 0, |
| 87 | }; |
| 88 | |
| 89 | private renderedElements = new Map<string, HTMLElement>(); |
| 90 | private scrollRAF: number | null = null; |
| 91 | |
| 92 | // Variable height tracking |
| 93 | private itemHeights = new Map<number, number>(); // index -> measured height |
| 94 | private positionCache: number[] = []; // Cumulative positions [0, 60, 125, 200...] |
| 95 | private totalHeight = 0; |
| 96 | private resizeObserver: ResizeObserver | null = null; |
| 97 | private measurementRAF: number | null = null; |
| 98 | private pendingMeasurements = new Set<number>(); |
| 99 | private invalidatedKeys = new Set<string>(); |
| 100 | |
| 101 | constructor(options: VirtualScrollerOptions<T>) { |
| 102 | this.container = options.container; |
| 103 | this.items = options.items; |
| 104 | this.estimatedHeight = options.itemHeight ?? 0; // Will be calculated if not provided |
| 105 | this.overscan = options.overscan ?? 5; |
| 106 | this.renderItem = options.renderItem; |
| 107 | this.getItemKey = options.getItemKey ?? ((item, index) => String(index)); |
| 108 | |
| 109 | this.setupDOM(); |
| 110 | this.attachScrollListener(); |
| 111 | this.setupResizeObserver(); |
| 112 | |
| 113 | // If no itemHeight provided, calculate from sample |
| 114 | if (!options.itemHeight && this.items.length > 0) { |
| 115 | this.calculateEstimatedHeight(); |
| 116 | } |
| 117 | |
| 118 | this.rebuildPositionCache(); |
| 119 | this.updateVisibleRange(); |
| 120 | } |
| 121 | |
| 122 | private setupDOM(): void { |
| 123 | // Clear existing content |
| 124 | this.container.empty(); |
| 125 | |
| 126 | // Container should just be relative, parent handles overflow |
| 127 | this.container.classList.remove("tn-static-margin-top-12px-91e0f558"); |
nothing calls this directly
no test coverage detected