UNPKG

@tanstack/angular-virtual

Version:

Headless UI for virtualizing scrollable elements in Angular

227 lines (210 loc) 6.51 kB
import { ApplicationRef, DestroyRef, Injector, afterRenderEffect, assertInInjectionContext, computed, inject, linkedSignal, runInInjectionContext, untracked, } from '@angular/core' import { Virtualizer, elementScroll, observeElementOffset, observeElementRect, observeWindowOffset, observeWindowRect, windowScroll, } from '@tanstack/virtual-core' import { signalProxy } from './proxy' import type { ElementRef } from '@angular/core' import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' import type { AngularVirtualizer } from './types' export * from '@tanstack/virtual-core' export * from './types' export type AngularVirtualizerOptions< TScrollElement extends Element | Window, TItemElement extends Element, > = VirtualizerOptions<TScrollElement, TItemElement> & { /** * Whether to flush the DOM using `ApplicationRef.tick()` * @default true * */ useApplicationRefTick?: boolean } export type AngularExtensionOptions = { /** * The injector to use for the virtualizer. * @default inject(Injector) */ injector?: Injector } // Flush CD after virtual-core updates so template bindings hit the DOM // before the next frame's scroll reconciliation reads `scrollHeight`. function injectScheduleDomFlushViaAppRefTick() { const appRef = inject(ApplicationRef) const destroyRef = inject(DestroyRef) let hostDestroyed = false destroyRef.onDestroy(() => { hostDestroyed = true }) let domFlushQueued = false return () => { if (domFlushQueued) return domFlushQueued = true queueMicrotask(() => { domFlushQueued = false if (hostDestroyed) return appRef.tick() }) } } function injectVirtualizerBase< TScrollElement extends Element | Window, TItemElement extends Element, >( options: () => AngularVirtualizerOptions<TScrollElement, TItemElement>, extensions: AngularExtensionOptions = {}, ) { let injector = extensions.injector if (!injector) { assertInInjectionContext(injectVirtualizerBase) injector = inject(Injector) } return runInInjectionContext(injector, () => { const scheduleDomFlush = injectScheduleDomFlushViaAppRefTick() const resolvedOptions = computed< VirtualizerOptions<TScrollElement, TItemElement> >(() => { const { useApplicationRefTick = true, ..._options } = options() return { ..._options, onChange: (instance, sync) => { reactiveVirtualizer.set(instance) if (useApplicationRefTick) { scheduleDomFlush() } _options.onChange?.(instance, sync) }, } }) // Computed here is used to lazily initialize the Virtualizer instance, // allowing it to be created after input/model signals are initialized. // Options are untracked to maintain a single instance of the Virtualizer. const lazyVirtualizer = computed( () => new Virtualizer(untracked(resolvedOptions)), ) // The reference in onChange is safe since computed signals are not evaluated eagerly. const reactiveVirtualizer = linkedSignal( () => { const virtualizer = lazyVirtualizer() // If setOptions does not call onChange, it's safe to call it here virtualizer.setOptions(resolvedOptions()) return virtualizer }, { equal: () => false }, ) afterRenderEffect((cleanup) => { cleanup(lazyVirtualizer()._didMount()) }) afterRenderEffect(() => { reactiveVirtualizer()._willUpdate() }) return signalProxy( reactiveVirtualizer, // Methods that pass through: call on the instance without tracking the signal read [ '_didMount', '_willUpdate', 'calculateRange', 'getVirtualIndexes', 'measure', 'measureElement', 'resizeItem', 'scrollBy', 'scrollToIndex', 'scrollToOffset', 'setOptions', ], // Attributes that will be transformed to signals [ 'isScrolling', 'measurementsCache', 'options', 'range', 'scrollDirection', 'scrollElement', 'scrollOffset', 'scrollRect', ], // Methods that will be tracked to the virtualizer signal [ 'getOffsetForAlignment', 'getOffsetForIndex', 'getVirtualItemForOffset', 'indexFromElement', ], // Zero-arg methods exposed as computed signals ['getTotalSize', 'getVirtualItems'], // The rest is passed as is, and can be accessed or called before initialization ) as unknown as AngularVirtualizer<TScrollElement, TItemElement> }) } export function injectVirtualizer< TScrollElement extends Element, TItemElement extends Element, >( options: () => PartialKeys< Omit< AngularVirtualizerOptions<TScrollElement, TItemElement>, 'getScrollElement' >, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' > & { scrollElement: ElementRef<TScrollElement> | TScrollElement | undefined }, ): AngularVirtualizer<TScrollElement, TItemElement> { return injectVirtualizerBase<TScrollElement, TItemElement>(() => { const _options = options() return { observeElementRect: observeElementRect, observeElementOffset: observeElementOffset, scrollToFn: elementScroll, getScrollElement: () => { const elementOrRef = _options.scrollElement return ( (isElementRef(elementOrRef) ? elementOrRef.nativeElement : elementOrRef) ?? null ) }, ..._options, } }) } function isElementRef<T extends Element>( elementOrRef: ElementRef<T> | T | undefined, ): elementOrRef is ElementRef<T> { return elementOrRef != null && 'nativeElement' in elementOrRef } export function injectWindowVirtualizer<TItemElement extends Element>( options: () => PartialKeys< AngularVirtualizerOptions<Window, TItemElement>, | 'getScrollElement' | 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' >, ): AngularVirtualizer<Window, TItemElement> { return injectVirtualizerBase<Window, TItemElement>(() => ({ getScrollElement: () => (typeof document !== 'undefined' ? window : null), observeElementRect: observeWindowRect, observeElementOffset: observeWindowOffset, scrollToFn: windowScroll, initialOffset: () => (typeof document !== 'undefined' ? window.scrollY : 0), ...options(), })) }