listine
Version:
Angular virtual scroll component supporting variable item heights.
237 lines (232 loc) • 15.2 kB
JavaScript
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