UNPKG

listine

Version:

Angular virtual scroll component supporting variable item heights.

237 lines (232 loc) 15.2 kB
import { NgTemplateOutlet } from '@angular/common'; import * as i0 from '@angular/core'; import { inject, NgZone, DestroyRef, ChangeDetectorRef, input, output, viewChild, viewChildren, signal, afterNextRender, effect, untracked, ChangeDetectionStrategy, Component } from '@angular/core'; /** * `s73-variable-virtual-scroll` * * A lightweight, customizable virtual scroll component that supports variable item heights. * Efficiently renders only visible items to improve performance for large lists. * * ## Inputs: * - `items`: List of data items to display * - `viewportHeight`: Height of the scrollable container (default: 400px) * - `buffer`: Number of extra items rendered above and below the viewport for smooth scrolling (default: 5) * - `itemTemplate`: Angular template for rendering each item * - `initialItemHeight`: Default item height before measurement (default: 50px) * - `scrollResetTrigger`: Increment (or any new value) to run `scrollToTop()` when the list should jump back to the top (e.g. after filter/search). * * ## Outputs: * - `scrollEmitter`: Emits scroll position on every scroll event */ class VariableVirtualScrollComponent { ngZone = inject(NgZone); destroyRef = inject(DestroyRef); changeDetectorRef = inject(ChangeDetectorRef); /** * Running maximum of measured row heights. Used when `items` is replaced so new estimates * are not stuck at `initialItemHeight` while real rows are taller (prevents overlap during fast filter). */ peakMeasuredItemHeight = 0; /** List of all items to render */ items = input([], ...(ngDevMode ? [{ debugName: "items" }] : /* istanbul ignore next */ [])); /** Height of the scrollable viewport in pixels */ viewportHeight = input(400, ...(ngDevMode ? [{ debugName: "viewportHeight" }] : /* istanbul ignore next */ [])); /** Number of extra items to render above and below the viewport */ buffer = input(5, ...(ngDevMode ? [{ debugName: "buffer" }] : /* istanbul ignore next */ [])); /** Template reference for rendering each item */ itemTemplate = input.required(...(ngDevMode ? [{ debugName: "itemTemplate" }] : /* istanbul ignore next */ [])); /** Flag to track if panel is open */ panelOpen = input(false, ...(ngDevMode ? [{ debugName: "panelOpen" }] : /* istanbul ignore next */ [])); /** Initial estimated height of each item before actual measurement */ initialItemHeight = input(50, ...(ngDevMode ? [{ debugName: "initialItemHeight" }] : /* istanbul ignore next */ [])); /** * Bump this value whenever the list should reset to the top (calls `scrollToTop()`). * Does not scroll on the initial value; only when the bound value changes. */ scrollToTopTrigger = input(0, ...(ngDevMode ? [{ debugName: "scrollToTopTrigger" }] : /* istanbul ignore next */ [])); /** Emits scroll position whenever user scrolls */ scrollEmitter = output(); /** Emits when user scrolls to the end of the list */ scrollToEnd = output(); /** Reference to the scrolling container */ scrollerRef = viewChild('scroller', ...(ngDevMode ? [{ debugName: "scrollerRef" }] : /* istanbul ignore next */ [])); /** Rendered item elements (used for height measurement) */ itemElements = viewChildren('itemElement', ...(ngDevMode ? [{ debugName: "itemElements" }] : /* istanbul ignore next */ [])); /** Stores measured heights of items */ itemHeights = signal([], ...(ngDevMode ? [{ debugName: "itemHeights" }] : /* istanbul ignore next */ [])); /** Stores calculated top offset for each item */ itemTops = signal([], ...(ngDevMode ? [{ debugName: "itemTops" }] : /* istanbul ignore next */ [])); /** Total height of all items (used to simulate full scrollable area) */ totalContentHeight = signal(0, ...(ngDevMode ? [{ debugName: "totalContentHeight" }] : /* istanbul ignore next */ [])); /** Index of the first visible item (including buffer) */ visibleStart = signal(0, ...(ngDevMode ? [{ debugName: "visibleStart" }] : /* istanbul ignore next */ [])); /** Index of the last visible item (including buffer) */ visibleEnd = signal(0, ...(ngDevMode ? [{ debugName: "visibleEnd" }] : /* istanbul ignore next */ [])); /** Items currently visible in the viewport */ visibleItems = signal([], ...(ngDevMode ? [{ debugName: "visibleItems" }] : /* istanbul ignore next */ [])); /** ResizeObserver to watch for item height changes (deferred measure, matching legacy pattern). */ resizeObserver = new ResizeObserver(() => { this.ngZone.run(() => { requestAnimationFrame(() => { this.ngZone.run(() => { this.measureItemHeights(); }); }); }); }); /** Previous scroll position to detect actual scrolling */ previousScrollTop = 0; /** Last seen `scrollResetTrigger` to detect changes without firing on first run */ previousScrollResetTrigger; constructor() { this.destroyRef.onDestroy(() => this.resizeObserver.disconnect()); afterNextRender(() => { this.calculateHeights(); this.onScroll(); }); effect(() => { this.items(); untracked(() => { this.initializeHeights(); this.calculateHeights(); this.onScroll(); }); }); effect(() => { if (this.panelOpen()) { untracked(() => { queueMicrotask(() => this.onScroll()); }); } }); effect(() => { const trigger = this.scrollToTopTrigger(); untracked(() => { if (this.previousScrollResetTrigger !== undefined && trigger !== this.previousScrollResetTrigger) { // Run after `items` and the host view have settled so `#scroller` exists and scrollTop applies. queueMicrotask(() => this.scrollToTop()); } this.previousScrollResetTrigger = trigger; }); }); } /** Initializes item height and position tracking arrays from `items` (`itemHeights.length` matches `items.length`). */ initializeHeights() { const list = this.items() ?? []; const estimate = Math.max(this.initialItemHeight(), this.peakMeasuredItemHeight); this.itemHeights.set(list.map(() => estimate)); this.updateItemTops(); } /** Resets scroll to top and rebuilds height estimates (e.g. after filter/search). */ scrollToTop() { const el = this.scrollerRef()?.nativeElement; if (el) { el.scrollTop = 0; } this.previousScrollTop = 0; this.onScroll(); } /** Recalculates item top positions and content height */ calculateHeights() { this.updateItemTops(); } /** Updates top positions of all items based on their current heights */ updateItemTops() { const tops = []; let top = 0; for (const height of this.itemHeights()) { tops.push(top); top += height; } this.itemTops.set(tops); this.totalContentHeight.set(top); } /** * Returns the top position of an item by index * @param index - index of the item */ getItemTop(index) { return this.itemTops()[index] ?? 0; } /** Scroll event handler: calculates which items should be visible */ onScroll() { const el = this.scrollerRef()?.nativeElement; if (!el) { return; } const scrollTop = el.scrollTop; const viewportBottom = scrollTop + this.viewportHeight(); const list = this.items() ?? []; const buf = this.buffer(); let start = 0; while (start < list.length && this.getItemTop(start + 1) < scrollTop) { start++; } let end = start; while (end < list.length && this.getItemTop(end) < viewportBottom) { end++; } this.visibleStart.set(Math.max(0, start - buf)); this.visibleEnd.set(Math.min(list.length, end + buf)); this.updateVisibleItems(); this.scrollEmitter.emit(scrollTop); const scrollHeight = el.scrollHeight; const clientHeight = el.clientHeight; const isAtEnd = scrollTop + clientHeight >= scrollHeight - 1; if (isAtEnd && scrollTop > this.previousScrollTop && scrollTop > 0) { this.scrollToEnd.emit(); } this.previousScrollTop = scrollTop; } /** Updates the list of items to be rendered based on scroll position */ updateVisibleItems() { const list = this.items() ?? []; this.visibleItems.set(list.slice(this.visibleStart(), this.visibleEnd())); // Run a sync CD pass so `#itemElement` nodes exist, measure, then remeasure on the next frame after layout/fonts settle. queueMicrotask(() => { this.ngZone.run(() => { this.changeDetectorRef.detectChanges(); this.measureItemHeights(); requestAnimationFrame(() => { this.ngZone.run(() => this.measureItemHeights()); }); }); }); } /** Measures actual heights of rendered DOM elements and updates tracking */ measureItemHeights() { const start = this.visibleStart(); const next = [...this.itemHeights()]; let changed = false; this.itemElements().forEach((el, idx) => { const index = start + idx; const height = el.nativeElement.offsetHeight; if (height > 0) { this.peakMeasuredItemHeight = Math.max(this.peakMeasuredItemHeight, height); } if (next[index] !== height) { next[index] = height; changed = true; this.resizeObserver.observe(el.nativeElement); } }); if (changed) { this.itemHeights.set(next); } this.updateItemTops(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: VariableVirtualScrollComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.16", type: VariableVirtualScrollComponent, isStandalone: true, selector: "listine-variable-virtual-scroll", inputs: { items: { classPropertyName: "items", publicName: "items", isSignal: true, isRequired: false, transformFunction: null }, viewportHeight: { classPropertyName: "viewportHeight", publicName: "viewportHeight", isSignal: true, isRequired: false, transformFunction: null }, buffer: { classPropertyName: "buffer", publicName: "buffer", isSignal: true, isRequired: false, transformFunction: null }, itemTemplate: { classPropertyName: "itemTemplate", publicName: "itemTemplate", isSignal: true, isRequired: true, transformFunction: null }, panelOpen: { classPropertyName: "panelOpen", publicName: "panelOpen", isSignal: true, isRequired: false, transformFunction: null }, initialItemHeight: { classPropertyName: "initialItemHeight", publicName: "initialItemHeight", isSignal: true, isRequired: false, transformFunction: null }, scrollToTopTrigger: { classPropertyName: "scrollToTopTrigger", publicName: "scrollToTopTrigger", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { scrollEmitter: "scrollEmitter", scrollToEnd: "scrollToEnd" }, viewQueries: [{ propertyName: "scrollerRef", first: true, predicate: ["scroller"], descendants: true, isSignal: true }, { propertyName: "itemElements", predicate: ["itemElement"], descendants: true, isSignal: true }], ngImport: i0, template: "<div #scroller class=\"scroll-container\" (scroll)=\"onScroll()\" [style.height.px]=\"viewportHeight()\">\n <!-- totalContentHeight -->\n <div class=\"total-height\" [style.height.px]=\"viewportHeight()\">\n @for (item of visibleItems(); track item; let i = $index) {\n <div #itemElement class=\"item\" [style.transform]=\"'translateY(' + getItemTop(visibleStart() + i) + 'px)'\">\n <ng-container\n *ngTemplateOutlet=\"itemTemplate(); context: { $implicit: item, index: visibleStart() + i }\"\n ></ng-container>\n </div>\n }\n </div>\n</div>\n", styles: [".scroll-container{overflow-y:auto;position:relative;width:100%;background:#fff}.total-height{position:relative;width:100%}.item{position:absolute;width:100%;box-sizing:border-box;will-change:transform}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.16", ngImport: i0, type: VariableVirtualScrollComponent, decorators: [{ type: Component, args: [{ selector: 'listine-variable-virtual-scroll', imports: [NgTemplateOutlet], standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, template: "<div #scroller class=\"scroll-container\" (scroll)=\"onScroll()\" [style.height.px]=\"viewportHeight()\">\n <!-- totalContentHeight -->\n <div class=\"total-height\" [style.height.px]=\"viewportHeight()\">\n @for (item of visibleItems(); track item; let i = $index) {\n <div #itemElement class=\"item\" [style.transform]=\"'translateY(' + getItemTop(visibleStart() + i) + 'px)'\">\n <ng-container\n *ngTemplateOutlet=\"itemTemplate(); context: { $implicit: item, index: visibleStart() + i }\"\n ></ng-container>\n </div>\n }\n </div>\n</div>\n", styles: [".scroll-container{overflow-y:auto;position:relative;width:100%;background:#fff}.total-height{position:relative;width:100%}.item{position:absolute;width:100%;box-sizing:border-box;will-change:transform}\n"] }] }], ctorParameters: () => [], propDecorators: { items: [{ type: i0.Input, args: [{ isSignal: true, alias: "items", required: false }] }], viewportHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewportHeight", required: false }] }], buffer: [{ type: i0.Input, args: [{ isSignal: true, alias: "buffer", required: false }] }], itemTemplate: [{ type: i0.Input, args: [{ isSignal: true, alias: "itemTemplate", required: true }] }], panelOpen: [{ type: i0.Input, args: [{ isSignal: true, alias: "panelOpen", required: false }] }], initialItemHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialItemHeight", required: false }] }], scrollToTopTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "scrollToTopTrigger", required: false }] }], scrollEmitter: [{ type: i0.Output, args: ["scrollEmitter"] }], scrollToEnd: [{ type: i0.Output, args: ["scrollToEnd"] }], scrollerRef: [{ type: i0.ViewChild, args: ['scroller', { isSignal: true }] }], itemElements: [{ type: i0.ViewChildren, args: ['itemElement', { isSignal: true }] }] } }); /* * Public API Surface of Listine */ /** * Generated bundle index. Do not edit. */ export { VariableVirtualScrollComponent }; //# sourceMappingURL=listine.mjs.map