All files / components/virtual-scroller virtual-scroller.component.ts

97.95% Statements 48/49
100% Branches 6/6
83.33% Functions 10/12
100% Lines 44/44

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 1351x 1x 1x 1x 1x 1x                         1x 1x   1x     1x                                             1x 11x 11x     11x     11x     11x     11x     11x     11x     11x     11x     11x     11x     11x 7x 7x 7x       11x     11x       11x       11x   11x             11x   11x   13x 7x       5x         7x 7x 7x   7x 7x 7x   7x      
import { FlexibleVirtualScrollStrategy } from './flexible-virtual-scroll-strategy';
import { VirtualScrollerItemDirective } from './virtual-scroller-item.directive';
import { SpinnerComponent } from '../spinner/spinner.component';
import { CdkVirtualForOf, CdkVirtualScrollViewport, VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling';
import { NgTemplateOutlet } from '@angular/common';
import {
    AfterViewInit,
    ChangeDetectionStrategy,
    Component,
    computed,
    contentChild,
    DestroyRef,
    inject,
    input,
    OnInit,
    output,
    viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { debounceTime, filter, merge, throttleTime } from 'rxjs';
 
export { VirtualScrollerItemDirective } from './virtual-scroller-item.directive';
 
/** Default number of items from the end to trigger loading more */
const DEFAULT_LOAD_MORE_THRESHOLD = 5;
 
/**
 * Context provided to the item template
 */
export interface VirtualScrollerItemContext<T> {
    /** The item data */
    $implicit: T;
    /** Index of the item in the list */
    index: number;
}
 
@Component({
    selector: 'ui-virtual-scroller',
    imports: [NgTemplateOutlet, CdkVirtualScrollViewport, CdkVirtualForOf, SpinnerComponent],
    templateUrl: './virtual-scroller.component.html',
    styleUrl: './virtual-scroller.component.scss',
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [
        FlexibleVirtualScrollStrategy,
        { provide: VIRTUAL_SCROLL_STRATEGY, useExisting: FlexibleVirtualScrollStrategy },
    ],
})
export class VirtualScrollerComponent<T> implements OnInit, AfterViewInit {
    private readonly destroyRef = inject(DestroyRef);
    private readonly scrollStrategy = inject(FlexibleVirtualScrollStrategy);
 
    /** Items to display in the virtual scroll */
    readonly items = input.required<readonly T[]>();
 
    /** Fixed height of each item in pixels. When set, uses fixed-size strategy (better performance). When omitted, uses autosize strategy (variable-height items). */
    readonly itemSize = input<number | undefined>();
 
    /** Number of items from the end to trigger loadMore event */
    readonly loadMoreThreshold = input<number>(DEFAULT_LOAD_MORE_THRESHOLD);
 
    /** Total number of items available (for determining if more can be loaded) */
    readonly totalItems = input<number>(0);
 
    /** Whether more items are currently being loaded */
    readonly isLoading = input<boolean>(false);
 
    /** Track by function for cdkVirtualFor */
    readonly trackByFn = input<(index: number, item: T) => unknown>((index: number) => index);
 
    /** Emitted when more items should be loaded */
    readonly loadMore = output<void>();
 
    /** Reference to the viewport for scroll handling */
    readonly viewport = viewChild.required(CdkVirtualScrollViewport);
 
    /** Template directive for rendering each item */
    readonly itemTemplateDirective = contentChild(VirtualScrollerItemDirective);
 
    /** Get the template from the directive */
    readonly itemTemplate = computed(() => this.itemTemplateDirective()?.template);
 
    /** Whether all items have been loaded */
    readonly allItemsLoaded = computed(() => {
        const total = this.totalItems();
        const currentCount = this.items().length;
        return total > 0 && currentCount >= total;
    });
 
    /** Whether the loading indicator should be shown */
    readonly showLoadingIndicator = computed(() => this.isLoading() && this.items().length > 0);
 
    ngOnInit(): void {
        this.scrollStrategy.configure(this.itemSize());
    }
 
    ngAfterViewInit(): void {
        this.setupScrollListener();
    }
 
    private setupScrollListener(): void {
        const viewport = this.viewport();
 
        const scroll$ = viewport.elementScrolled().pipe(
            throttleTime(100, undefined, { leading: true, trailing: true }),
        );
 
        // Also check when CDK updates the rendered range — covers the case where
        // all items fit in the viewport without scrolling (e.g. wide screen).
        // debounceTime(0) collapses any synchronous batch of range updates into one emission.
        const rangeChange$ = viewport.renderedRangeStream.pipe(debounceTime(0));
 
        merge(scroll$, rangeChange$)
            .pipe(
                filter(() => !this.isLoading() && !this.allItemsLoaded()),
                filter(() => this.shouldLoadMore()),
                takeUntilDestroyed(this.destroyRef)
            )
            .subscribe(() => {
                this.loadMore.emit();
            });
    }
 
    private shouldLoadMore(): boolean {
        const viewport = this.viewport();
        const items = this.items();
        const threshold = this.loadMoreThreshold();
 
        const renderedRange = viewport.getRenderedRange();
        const lastRenderedIndex = renderedRange.end;
        const itemsRemaining = items.length - lastRenderedIndex;
 
        return itemsRemaining <= threshold;
    }
}