diff --git a/sample/sample-v-ui-app/src/app.html b/sample/sample-v-ui-app/src/app.html index 11e4c48..106910d 100644 --- a/sample/sample-v-ui-app/src/app.html +++ b/sample/sample-v-ui-app/src/app.html @@ -6,7 +6,7 @@
diff --git a/sample/sample-v-ui-app/src/app.ts b/sample/sample-v-ui-app/src/app.ts index 9a8edf3..fce0659 100644 --- a/sample/sample-v-ui-app/src/app.ts +++ b/sample/sample-v-ui-app/src/app.ts @@ -10,14 +10,26 @@ export class App { { route: ['', 'phone-list'], moduleId: PLATFORM.moduleName('./phone-list'), - nav: true, + nav: 1, title: 'Contacts' }, { route: 'issue-138', moduleId: PLATFORM.moduleName('./issue-138/sub-app'), - nav: true, + nav: 2, title: 'Issue 138' + }, + { + route: 'issue-129', + moduleId: PLATFORM.moduleName('./issue-129/sub-app'), + nav: 3, + title: 'Issue 129' + }, + { + route: 'issue-102', + moduleId: PLATFORM.moduleName('./issue-102/sub-app'), + nav: 4, + title: 'Issue 102' } ]); @@ -25,13 +37,5 @@ export class App { window['app'] = this; } - bind() { - this.check(); - } - - check() { - setInterval(() => { - this.virtualRepeat = window['virtualRepeat']; - }, 1500); - } + window = window; } diff --git a/sample/sample-v-ui-app/src/issue-102/sub-app.html b/sample/sample-v-ui-app/src/issue-102/sub-app.html new file mode 100644 index 0000000..71b84f8 --- /dev/null +++ b/sample/sample-v-ui-app/src/issue-102/sub-app.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/sample/sample-v-ui-app/src/issue-102/sub-app.ts b/sample/sample-v-ui-app/src/issue-102/sub-app.ts new file mode 100644 index 0000000..e24c6d6 --- /dev/null +++ b/sample/sample-v-ui-app/src/issue-102/sub-app.ts @@ -0,0 +1,47 @@ +interface IScrollContext { + isAtTop: boolean; + isAtBottom: boolean; + topIndex: number; +} + +export interface Person { + fname: string; + lname: string; +} + +const fNames = [ + // tslint:disable-next-line:max-line-length + 'Ford', 'Arthur', 'Trillian', 'Sneezy', 'Sleepy', 'Dopey', 'Doc', 'Happy', 'Bashful', 'Grumpy', 'Mufasa', 'Sarabi', 'Simba', 'Nala', 'Kiara', 'Kovu', 'Timon', 'Pumbaa', 'Rafiki', 'Shenzi' +]; +const lNames = [ + // tslint:disable-next-line:max-line-length + 'Prefect', 'Dent', 'Astra', 'Adams', 'Baker', 'Clark', 'Davis', 'Evans', 'Frank', 'Ghosh', 'Hills', 'Irwin', 'Jones', 'Klein', 'Lopez', 'Mason', 'Nalty', 'Ochoa', 'Patel', 'Quinn', 'Reily', 'Smith', 'Trott', 'Usman', 'Valdo', 'White', 'Xiang', 'Yakub', 'Zafar' +]; +export class App { + private people: Person[]; + + constructor() { + this.people = [ + { fname: fNames[0], lname: lNames[0] }, + { fname: fNames[1], lname: lNames[1] }, + { fname: fNames[2], lname: lNames[2] } + ]; + this.push30(undefined, 0); + } + + public push30(scrollContext?: IScrollContext, count = 30) { + console.log('Issue-102 getting more...'); + // if (scrollContext) { + // console.log('Issue-129 getting more:', JSON.stringify(scrollContext, undefined, 2)); + // } + if (!scrollContext || scrollContext.isAtBottom) { + for (let i = 0; i < count; i++) { + this.people.push({ + fname: fNames[Math.floor(Math.random() * fNames.length)], + lname: lNames[Math.floor(Math.random() * lNames.length)] + }); + } + } + console.log('Population size:', this.people.length); + } +} diff --git a/sample/sample-v-ui-app/src/issue-129/sub-app.html b/sample/sample-v-ui-app/src/issue-129/sub-app.html new file mode 100644 index 0000000..71b84f8 --- /dev/null +++ b/sample/sample-v-ui-app/src/issue-129/sub-app.html @@ -0,0 +1,27 @@ + \ No newline at end of file diff --git a/sample/sample-v-ui-app/src/issue-129/sub-app.ts b/sample/sample-v-ui-app/src/issue-129/sub-app.ts new file mode 100644 index 0000000..1d170c8 --- /dev/null +++ b/sample/sample-v-ui-app/src/issue-129/sub-app.ts @@ -0,0 +1,47 @@ +interface IScrollContext { + isAtTop: boolean; + isAtBottom: boolean; + topIndex: number; +} + +export interface Person { + fname: string; + lname: string; +} + +const fNames = [ + // tslint:disable-next-line:max-line-length + 'Ford', 'Arthur', 'Trillian', 'Sneezy', 'Sleepy', 'Dopey', 'Doc', 'Happy', 'Bashful', 'Grumpy', 'Mufasa', 'Sarabi', 'Simba', 'Nala', 'Kiara', 'Kovu', 'Timon', 'Pumbaa', 'Rafiki', 'Shenzi' +]; +const lNames = [ + // tslint:disable-next-line:max-line-length + 'Prefect', 'Dent', 'Astra', 'Adams', 'Baker', 'Clark', 'Davis', 'Evans', 'Frank', 'Ghosh', 'Hills', 'Irwin', 'Jones', 'Klein', 'Lopez', 'Mason', 'Nalty', 'Ochoa', 'Patel', 'Quinn', 'Reily', 'Smith', 'Trott', 'Usman', 'Valdo', 'White', 'Xiang', 'Yakub', 'Zafar' +]; +export class App { + private people: Person[]; + + constructor() { + this.people = [ + { fname: fNames[0], lname: lNames[0] }, + { fname: fNames[1], lname: lNames[1] }, + { fname: fNames[2], lname: lNames[2] } + ]; + this.push30(undefined, 5); + } + + public push30(scrollContext?: IScrollContext, count = 30) { + console.log('Issue-129 getting more...'); + // if (scrollContext) { + // console.log('Issue-129 getting more:', JSON.stringify(scrollContext, undefined, 2)); + // } + if (!scrollContext || scrollContext.isAtBottom) { + for (let i = 0; i < count; i++) { + this.people.push({ + fname: fNames[Math.floor(Math.random() * fNames.length)], + lname: lNames[Math.floor(Math.random() * lNames.length)] + }); + } + } + console.log('Population size:', this.people.length); + } +} diff --git a/src/array-virtual-repeat-strategy.ts b/src/array-virtual-repeat-strategy.ts index e21e867..3961754 100644 --- a/src/array-virtual-repeat-strategy.ts +++ b/src/array-virtual-repeat-strategy.ts @@ -1,6 +1,6 @@ import { ArrayRepeatStrategy, createFullOverrideContext } from 'aurelia-templating-resources'; import { updateVirtualOverrideContexts, rebindAndMoveView, getElementDistanceToBottomViewPort } from './utilities'; -import { IVirtualRepeat, IVirtualRepeatStrategy } from './interfaces'; +import { IVirtualRepeat, IVirtualRepeatStrategy, IView } from './interfaces'; import { ViewSlot } from 'aurelia-templating'; import { mergeSplice } from 'aurelia-binding'; @@ -14,6 +14,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I repeat.addView(overrideContext.bindingContext, overrideContext); } /** + * @override * Handle the repeat's collection instance changing. * @param repeat The repeater instance. * @param items The new array instance. @@ -23,6 +24,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I } /** + * @override * Handle the repeat's collection instance mutating. * @param repeat The repeat instance. * @param array The modified array. @@ -170,14 +172,15 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I return undefined; } - _removeViewAt(repeat: IVirtualRepeat, collectionIndex: number, returnToCache: boolean, j: number, removedLength: number): any { - let viewOrPromise; - let view; + /**@internal */ + _removeViewAt(repeat: IVirtualRepeat, collectionIndex: number, returnToCache: boolean, removeIndex: number, removedLength: number): any { + let viewOrPromise: IView | Promise; + let view: IView; let viewSlot = repeat.viewSlot; let viewCount = repeat.viewCount(); - let viewAddIndex; + let viewAddIndex: number; let removeMoreThanInDom = removedLength > viewCount; - if (repeat._viewsLength <= j) { + if (repeat._viewsLength <= removeIndex) { repeat._bottomBufferHeight = repeat._bottomBufferHeight - (repeat.itemHeight); repeat._adjustBufferHeights(); return; @@ -196,7 +199,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I let lastViewItem = repeat._getLastViewItem(); collectionAddIndex = repeat.items.indexOf(lastViewItem) + 1; } else { - collectionAddIndex = j; + collectionAddIndex = removeIndex; } repeat._bottomBufferHeight = repeat._bottomBufferHeight - (repeat.itemHeight); } else if (repeat._topBufferHeight > 0) { @@ -207,7 +210,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I let data = repeat.items[collectionAddIndex]; if (data) { let overrideContext = createFullOverrideContext(repeat, data, collectionAddIndex, repeat.items.length); - view = repeat.viewFactory.create(); + view = repeat.viewFactory.create() as IView; view.bind(overrideContext.bindingContext, overrideContext); } } @@ -233,16 +236,22 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I repeat._adjustBufferHeights(); } + /**@internal */ _isIndexBeforeViewSlot(repeat: IVirtualRepeat, viewSlot: ViewSlot, index: number): boolean { let viewIndex = this._getViewIndex(repeat, viewSlot, index); return viewIndex < 0; } + /**@internal */ _isIndexAfterViewSlot(repeat: IVirtualRepeat, viewSlot: ViewSlot, index: number): boolean { let viewIndex = this._getViewIndex(repeat, viewSlot, index); return viewIndex > repeat._viewsLength - 1; } + /** + * @internal + * Calculate real index of a given index, based on existing buffer height and item height + */ _getViewIndex(repeat: IVirtualRepeat, viewSlot: ViewSlot, index: number): number { if (repeat.viewCount() === 0) { return -1; @@ -252,6 +261,7 @@ export class ArrayVirtualRepeatStrategy extends ArrayRepeatStrategy implements I return index - topBufferItems; } + /**@internal */ _handleAddedSplices(repeat: IVirtualRepeat, array: Array, splices: any): void { let arrayLength = array.length; let viewSlot = repeat.viewSlot; diff --git a/src/aurelia-ui-virtualization.ts b/src/aurelia-ui-virtualization.ts index a5f3162..7201a02 100644 --- a/src/aurelia-ui-virtualization.ts +++ b/src/aurelia-ui-virtualization.ts @@ -12,3 +12,7 @@ export { VirtualRepeat, InfiniteScrollNext }; + +export { + IScrollNextScrollContext +} from './interfaces'; diff --git a/src/infinite-scroll-next.ts b/src/infinite-scroll-next.ts index 97c8219..a9b5079 100644 --- a/src/infinite-scroll-next.ts +++ b/src/infinite-scroll-next.ts @@ -1,5 +1,3 @@ -import { OverrideContext, Scope } from 'aurelia-binding'; - // Placeholder attribute to prohibit use of this attribute name in other places export class InfiniteScrollNext { @@ -10,15 +8,4 @@ export class InfiniteScrollNext { name: 'infinite-scroll-next' }; } - - /**@internal */ - scope: Scope; - - bind(bindingContext: any, overrideContext: OverrideContext): void { - this.scope = { bindingContext, overrideContext }; - } - - unbind() { - this.scope = undefined; - } } diff --git a/src/interfaces.ts b/src/interfaces.ts index 3e9ab0f..ef71bba 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -3,6 +3,12 @@ import { ViewSlot, View, ViewFactory, BoundViewFactory, Controller } from 'aurel import { Scope, Binding, OverrideContext } from 'aurelia-binding'; import { TaskQueue } from 'aurelia-task-queue'; +export interface IScrollNextScrollContext { + topIndex: number; + isAtBottom: boolean; + isAtTop: boolean; +} + /**@internal */ declare module 'aurelia-binding' { interface ObserverLocator { @@ -11,11 +17,7 @@ declare module 'aurelia-binding' { interface OverrideContext { $index: number; - $scrollContext: { - topIndex: number; - isAtBottom: boolean; - isAtTop: boolean; - }; + $scrollContext: IScrollNextScrollContext; $first: boolean; $last: boolean; $middle: boolean; @@ -55,13 +57,25 @@ export interface IVirtualRepeatStrategy extends RepeatStrategy { export interface IVirtualRepeat extends Repeat { - /**@internal */ _first: number; + /** + * @internal + * First view index, for proper follow up calculations + */ + _first: number; - /**@internal */ _previousFirst: number; + /** + * @internal + * Preview first view index, for proper determination of delta + */ + _previousFirst: number; /**@internal */ _viewsLength: number; - /**@internal */ _lastRebind: number; + /** + * @internal + * Last rebound view index, for determining rendered range + */ + _lastRebind: number; /**@internal */ _topBufferHeight: number; @@ -89,6 +103,7 @@ export interface IVirtualRepeat extends Repeat { /**@internal */ viewSlot: ViewSlot & { children: IView[] }; + items: any[]; itemHeight: number; strategy: IVirtualRepeatStrategy; @@ -141,3 +156,19 @@ export interface ITemplateStrategy { * Override `bindingContext` and `overrideContext` on `View` interface */ export type IView = View & Scope; + +// export const enum IVirtualRepeatState { +// isAtTop = 0b0_000000_000, +// isLastIndex = 0b0_000000_000, +// scrollingDown = 0b0_000000_000, +// scrollingUp = 0b0_000000_000, +// switchedDirection = 0b0_000000_000, +// isAttached = 0b0_000000_000, +// ticking = 0b0_000000_000, +// fixedHeightContainer = 0b0_000000_000, +// hasCalculatedSizes = 0b0_000000_000, +// calledGetMore = 0b0_000000_000, +// skipNextScrollHandle = 0b0_000000_000, +// handlingMutations = 0b0_000000_000, +// isScrolling = 0b0_000000_000 +// } diff --git a/src/virtual-repeat.ts b/src/virtual-repeat.ts index 1b7d435..01805ed 100644 --- a/src/virtual-repeat.ts +++ b/src/virtual-repeat.ts @@ -1,4 +1,4 @@ -import {ObserverLocator, Scope, Expression, Disposable, ICollectionObserverSplice, OverrideContext} from 'aurelia-binding'; +import {ObserverLocator, Scope, Expression, Disposable, ICollectionObserverSplice, OverrideContext, BindingExpression} from 'aurelia-binding'; import { BoundViewFactory, ViewSlot, @@ -28,8 +28,10 @@ import { IVirtualRepeat, IVirtualRepeatStrategy, ITemplateStrategy, - IView + IView, + IScrollNextScrollContext } from './interfaces'; +import { TaskQueue } from '../sample/sample-v-ui-app/node_modules/aurelia-framework/dist/aurelia-framework'; const enum VirtualRepeatCallContext { handleCollectionMutated = 'handleCollectionMutated', @@ -50,12 +52,23 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { type: 'attribute', name: 'virtual-repeat', templateController: true, - bindables: ['items', 'local'] as any // Wrong typings in templating + // Wrong typings in templating + bindables: ['items', 'local'] as any }; } - /**@internal*/ _first = 0; - /**@internal*/ _previousFirst = 0; + /** + * @internal + * First view index, for proper follow up calculations + */ + _first = 0; + + /** + * @internal + * Preview first view index, for proper determination of delta + */ + _previousFirst = 0; + /**@internal*/ _viewsLength = 0; /**@internal*/ _lastRebind = 0; /**@internal*/ _topBufferHeight = 0; @@ -135,7 +148,10 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { /**@internal */ private domHelper: DomHelper; - /**@internal */ + /** + * @internal + * Temporary snapshot of items list count. Updated regularly to determinate calculation need + */ private _itemsLength: number; /**@internal */ @@ -145,13 +161,18 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { private scrollListener: () => any; /**@internal */ - _sizeInterval: any; + private _sizeInterval: any; + + /**@internal */ + private _calcDistanceToTopInterval: any; + + /**@internal */ + private taskQueue: TaskQueue; templateStrategy: ITemplateStrategy; topBuffer: HTMLElement; bottomBuffer: HTMLElement; - calcDistanceToTopInterval: any; itemHeight: number; movedViewsCount: number; @@ -190,9 +211,10 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { this.element = element; this.viewFactory = viewFactory; this.instruction = instruction; - this.viewSlot = viewSlot as any; + this.viewSlot = viewSlot as ViewSlot & { children: IView[] }; this.lookupFunctions = viewResources['lookupFunctions']; this.observerLocator = observerLocator; + this.taskQueue = observerLocator.taskQueue; this.strategyLocator = strategyLocator; this.templateStrategyLocator = templateStrategyLocator; this.sourceExpression = getItemsSourceExpression(this.instruction, 'virtual-repeat.for'); @@ -200,44 +222,48 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { this.domHelper = domHelper; } + /**@override */ + bind(bindingContext: any, overrideContext: OverrideContext): void { + this.scope = { bindingContext, overrideContext }; + } + /**@override */ attached(): void { this._isAttached = true; - let element = this.element; this._itemsLength = this.items.length; - this.templateStrategy = this.templateStrategyLocator.getStrategy(element); - this.scrollContainer = this.templateStrategy.getScrollContainer(element); - this.topBuffer = this.templateStrategy.createTopBufferElement(element); - this.bottomBuffer = this.templateStrategy.createBottomBufferElement(element); + + let element = this.element; + let templateStrategy = this.templateStrategy = this.templateStrategyLocator.getStrategy(element); + + let scrollListener = this.scrollListener = () => this._onScroll(); + let scrollContainer = this.scrollContainer = templateStrategy.getScrollContainer(element); + let topBuffer = this.topBuffer = templateStrategy.createTopBufferElement(element); + + this.bottomBuffer = templateStrategy.createBottomBufferElement(element); this.itemsChanged(); - this.scrollListener = () => this._onScroll(); - this.calcDistanceToTopInterval = setInterval(() => { - let distanceToTop = this.distanceToTop; - this.distanceToTop = this.domHelper.getElementDistanceToTopOfDocument(this.topBuffer); - this.distanceToTop += this.topBufferDistance; - if (distanceToTop !== this.distanceToTop) { + this._calcDistanceToTopInterval = PLATFORM.global.setInterval(() => { + let prevDistanceToTop = this.distanceToTop; + let currDistanceToTop = this.domHelper.getElementDistanceToTopOfDocument(topBuffer) + this.topBufferDistance; + this.distanceToTop = currDistanceToTop; + if (prevDistanceToTop !== currDistanceToTop) { this._handleScroll(); } }, 500); - this.distanceToTop = this.domHelper.getElementDistanceToTopOfDocument(this.templateStrategy.getFirstElement(this.topBuffer)); // When dealing with tables, there can be gaps between elements, causing distances to be messed up. Might need to handle this case here. - this.topBufferDistance = this.templateStrategy.getTopBufferDistance(this.topBuffer); + this.topBufferDistance = templateStrategy.getTopBufferDistance(topBuffer); + this.distanceToTop = this.domHelper + .getElementDistanceToTopOfDocument(templateStrategy.getFirstElement(topBuffer)); - if (this.domHelper.hasOverflowScroll(this.scrollContainer)) { + if (this.domHelper.hasOverflowScroll(scrollContainer)) { this._fixedHeightContainer = true; - this.scrollContainer.addEventListener('scroll', this.scrollListener); + scrollContainer.addEventListener('scroll', scrollListener); } else { - document.addEventListener('scroll', this.scrollListener); + document.addEventListener('scroll', scrollListener); } - } - - /**@override */ - bind(bindingContext: any, overrideContext: OverrideContext): void { - this.scope = { bindingContext, overrideContext }; - if (this._isAttached) { - this.itemsChanged(); + if (this.items.length < this.elementsInView && this.isLastIndex === undefined) { + this._getMore(true); } } @@ -253,22 +279,43 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { } else { document.removeEventListener('scroll', this.scrollListener); } + this.isLastIndex = undefined; this._fixedHeightContainer = false; this._resetCalculation(); this._isAttached = false; + this._itemsLength = 0; this.templateStrategy.removeBufferElements(this.element, this.topBuffer, this.bottomBuffer); - this.scrollContainer = null; + this.topBuffer = this.bottomBuffer = this.scrollContainer = this.scrollListener = null; this.scrollContainerHeight = 0; this.distanceToTop = 0; this.removeAllViews(true, false); this._unsubscribeCollection(); - clearInterval(this.calcDistanceToTopInterval); + clearInterval(this._calcDistanceToTopInterval); if (this._sizeInterval) { clearInterval(this._sizeInterval); } } /**@override */ + unbind(): void { + this.scope = null; + this.items = null; + this._itemsLength = 0; + } + + /** + * @override + * + * If `items` is truthy, do the following calculation/work: + * + * - container fixed height flag + * - necessary initial heights + * - create new collection observer & observe for changes + * - invoke `instanceChanged` on repeat strategy to create views / move views + * - update indices + * - update scrollbar position in special scenarios + * - handle scroll as if scroll event happened + */ itemsChanged(): void { this._unsubscribeCollection(); // still bound? @@ -303,14 +350,19 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { this.strategy.instanceChanged(this, items, this._first); if (shouldCalculateSize) { - this._lastRebind = this._first; // Reset rebinding + // Reset rebinding + this._lastRebind = this._first; if (reducingItems && previousLastViewIndex > this.items.length - 1) { // Do we need to set scrolltop so that we appear at the bottom of the list to match scrolling as far as we could? // We only want to execute this line if we're reducing such that it brings us to the bottom of the new list // Make sure we handle the special case of tables + // ------- + // Note: if branch is never the case anymore, + // keeping this code to keep the history of logic for future work if (this.scrollContainer.tagName === 'TBODY') { - let realScrollContainer = this.scrollContainer.parentNode.parentNode as Element; // tbody > table > container + // tbody > table > container + let realScrollContainer = this.scrollContainer.parentNode.parentNode as Element; realScrollContainer.scrollTop = realScrollContainer.scrollTop + (this.viewCount() * this.itemHeight); } else { this.scrollContainer.scrollTop = this.scrollContainer.scrollTop + (this.viewCount() * this.itemHeight); @@ -319,7 +371,8 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { if (!reducingItems) { // If we're expanding our items, then we need to reset our previous first for the next go around of scroll handling this._previousFirst = this._first; - this._scrollingDown = true; // Simulating the down scroll event to load up data appropriately + // Simulating the down scroll event to load up data appropriately + this._scrollingDown = true; this._scrollingUp = false; // Make sure we fix any state (we could have been at the last index before, but this doesn't get set until too late for scrolling) @@ -332,15 +385,9 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { } } - /**@override */ - unbind(): void { - this.scope = null; - this.items = null; - this._itemsLength = null; - } - /**@override */ handleCollectionMutated(collection: any[], changes: ICollectionObserverSplice[]): void { + // guard against multiple mutation, or mutation combined with instance mutation if (this.ignoreMutation) { return; } @@ -358,7 +405,7 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { } this.ignoreMutation = true; let newItems = this.sourceExpression.evaluate(this.scope, this.lookupFunctions); - this.observerLocator.taskQueue.queueMicroTask(() => this.ignoreMutation = false); + this.taskQueue.queueMicroTask(() => this.ignoreMutation = false); // call itemsChanged... if (newItems === this.items) { @@ -494,56 +541,66 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { /**@internal*/ _getMore(force?: boolean): void { - if (this.isLastIndex || this._first === 0 || force) { + if (this.isLastIndex || this._first === 0 || force === true) { if (!this._calledGetMore) { let executeGetMore = () => { this._calledGetMore = true; let firstView = this._getFirstView(); - let func = (firstView + let scrollNextAttrName = 'infinite-scroll-next'; + let func: string | (BindingExpression & { sourceExpression: Expression }) = (firstView && firstView.firstChild && firstView.firstChild.au - && firstView.firstChild.au['infinite-scroll-next']) - ? firstView.firstChild.au['infinite-scroll-next'].instruction.attributes['infinite-scroll-next'] + && firstView.firstChild.au[scrollNextAttrName]) + ? firstView.firstChild.au[scrollNextAttrName].instruction.attributes[scrollNextAttrName] : undefined; let topIndex = this._first; let isAtBottom = this._bottomBufferHeight === 0; let isAtTop = this._isAtTop; - let scrollContext = { + let scrollContext: IScrollNextScrollContext = { topIndex: topIndex, isAtBottom: isAtBottom, isAtTop: isAtTop }; - this.scope.overrideContext.$scrollContext = scrollContext; + let overrideContext = this.scope.overrideContext; + overrideContext.$scrollContext = scrollContext; if (func === undefined) { + // Still reset `_calledGetMore` flag as if it was invoked + // though this should not happen as presence of infinite-scroll-next attribute + // will make the value at least be an empty string + // keeping this logic here for future enhancement/evolution + this._calledGetMore = false; return null; } else if (typeof func === 'string') { - let getMoreFuncName = (this.view(0).firstChild as Element).getAttribute('infinite-scroll-next'); - let funcCall = this.scope.overrideContext.bindingContext[getMoreFuncName]; + let getMoreFuncName = (firstView.firstChild as Element).getAttribute(scrollNextAttrName); + let funcCall = overrideContext.bindingContext[getMoreFuncName]; if (typeof funcCall === 'function') { - let result = funcCall.call(this.scope.overrideContext.bindingContext, topIndex, isAtBottom, isAtTop); + let result = funcCall.call(overrideContext.bindingContext, topIndex, isAtBottom, isAtTop); if (!(result instanceof Promise)) { - this._calledGetMore = false; // Reset for the next time + // Reset for the next time + this._calledGetMore = false; } else { return result.then(() => { - this._calledGetMore = false; // Reset for the next time + // Reset for the next time + this._calledGetMore = false; }); } } else { - throw new Error("'infinite-scroll-next' must be a function or evaluate to one"); + throw new Error(`'${scrollNextAttrName}' must be a function or evaluate to one`); } } else if (func.sourceExpression) { - this._calledGetMore = false; // Reset for the next time + // Reset for the next time + this._calledGetMore = false; return func.sourceExpression.evaluate(this.scope); } else { - throw new Error("'infinite-scroll-next' must be a function or evaluate to one"); + throw new Error(`'${scrollNextAttrName}' must be a function or evaluate to one`); } return null; }; - this.observerLocator.taskQueue.queueMicroTask(executeGetMore); + this.taskQueue.queueMicroTask(executeGetMore); } } } @@ -592,20 +649,20 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { /**@internal*/ _unsubscribeCollection(): void { - if (this.collectionObserver) { - this.collectionObserver.unsubscribe(this.callContext, this); - this.collectionObserver = null; - this.callContext = null; + let collectionObserver = this.collectionObserver; + if (collectionObserver) { + collectionObserver.unsubscribe(this.callContext, this); + this.collectionObserver = this.callContext = null; } } /**@internal */ - _getFirstView(): View | null { + _getFirstView(): IView | null { return this.view(0); } /**@internal */ - _getLastView(): View | null { + _getLastView(): IView | null { return this.view(this.viewCount() - 1); } @@ -642,19 +699,19 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { /**@internal*/ _getIndexOfLastView(): number { - const lastView = this._getLastView() as IView; + const lastView = this._getLastView(); return lastView === null ? -1 : lastView.overrideContext.$index; } /**@internal*/ _getLastViewItem(): IView { - let lastView = this._getLastView() as IView; + let lastView = this._getLastView(); return lastView === null ? undefined : lastView.bindingContext[this.local]; } /**@internal*/ _getIndexOfFirstView(): number { - let firstView = this._getFirstView() as IView; + let firstView = this._getFirstView(); return firstView === null ? -1 : firstView.overrideContext.$index; } @@ -720,7 +777,8 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { } else { // Use case when items are added (we are adding scrollable space to the bottom) // We need to re-evaluate which is the true "first". If we've added items, then the previous "first" is actually too far down the list this._first = this._getIndexOfFirstView(); - let adjustedTopBufferHeight = this._first * this.itemHeight; // appropriate buffer height for top, might be 1 too long... + // appropriate buffer height for top, might be 1 too long... + let adjustedTopBufferHeight = this._first * this.itemHeight; this._topBufferHeight = adjustedTopBufferHeight; // But what about when we've only scrolled slightly down the list? We need to readjust the top buffer height then this._bottomBufferHeight = newBottomBufferHeight - adjustedTopBufferHeight; @@ -767,10 +825,11 @@ export class VirtualRepeat extends AbstractRepeater implements IVirtualRepeat { /**@internal*/ _observeCollection(): void { - this.collectionObserver = this.strategy.getCollectionObserver(this.observerLocator, this.items); - if (this.collectionObserver) { + let collectionObserver = this.strategy.getCollectionObserver(this.observerLocator, this.items); + if (collectionObserver) { this.callContext = VirtualRepeatCallContext.handleCollectionMutated; - this.collectionObserver.subscribe(this.callContext, this); + this.collectionObserver = collectionObserver; + collectionObserver.subscribe(this.callContext, this); } } diff --git a/test/utilities.ts b/test/utilities.ts index a874d0b..7710bd6 100644 --- a/test/utilities.ts +++ b/test/utilities.ts @@ -67,7 +67,10 @@ export function validateScrolledState(virtualRepeat: VirtualRepeat, viewModel: a let topBufferHeight = virtualRepeat.topBuffer.getBoundingClientRect().height; let bottomBufferHeight = virtualRepeat.bottomBuffer.getBoundingClientRect().height; let renderedItemsHeight = views.length * itemHeight; - expect(topBufferHeight + renderedItemsHeight + bottomBufferHeight).toBe(expectedHeight); + expect(topBufferHeight + renderedItemsHeight + bottomBufferHeight).toBe( + expectedHeight, + `Top buffer (${topBufferHeight}) + items height (${renderedItemsHeight}) + bottom buffer (${bottomBufferHeight}) should have been correct` + ); if (viewModel.items.length > views.length) { expect(topBufferHeight + bottomBufferHeight).toBeGreaterThan(0); @@ -91,3 +94,21 @@ export function validateScrolledState(virtualRepeat: VirtualRepeat, viewModel: a expect(overrideContext.$even).toBe(even); } } + +/** + * Manually dispatch a scroll event and validate scrolled state of virtual repeat + * + * Programatically set `scrollTop` of element specified with `elementSelector` query string + * (or `#scrollContainer` by default) to be equal with its `scrollHeight` + */ +export function validateScroll(virtualRepeat: VirtualRepeat, viewModel: any, itemHeight: number, element: Element, done: Function): void { + let event = new Event('scroll'); + element.scrollTop = element.scrollHeight; + element.dispatchEvent(event); + window.setTimeout(() => { + window.requestAnimationFrame(() => { + validateScrolledState(virtualRepeat, viewModel, itemHeight); + done(); + }); + }); +} diff --git a/test/virtual-repeat-integration.spec.ts b/test/virtual-repeat-integration.spec.ts index e7f3cf2..bbe6b1e 100644 --- a/test/virtual-repeat-integration.spec.ts +++ b/test/virtual-repeat-integration.spec.ts @@ -1,10 +1,10 @@ import './setup'; // import {Container} from 'aurelia-dependency-injection'; // import {TaskQueue} from 'aurelia-task-queue' -import {StageComponent, ComponentTester} from './component-tester'; +import { StageComponent, ComponentTester } from './component-tester'; import { PLATFORM } from 'aurelia-pal'; // import {TableStrategy} from '../src/template-strategy'; -import { createAssertionQueue, validateState, validateScrolledState } from './utilities'; +import { createAssertionQueue, validateState, validateScrolledState, Queue as AsyncQueue } from './utilities'; import { VirtualRepeat } from '../src/virtual-repeat'; PLATFORM.moduleName('src/virtual-repeat'); @@ -14,40 +14,46 @@ PLATFORM.moduleName('src/infinite-scroll-next'); describe('VirtualRepeat Integration', () => { // async queue - let nq = createAssertionQueue(); + let nq: AsyncQueue = createAssertionQueue(); let itemHeight = 100; - function validateScroll(virtualRepeat: VirtualRepeat, viewModel: any, done: Function, element?: string) { - let elem = document.getElementById(element || 'scrollContainer'); - let event = new Event('scroll'); - elem.scrollTop = elem.scrollHeight; - elem.dispatchEvent(event); - window.setTimeout(() => { - window.requestAnimationFrame(() => { - validateScrolledState(virtualRepeat, viewModel, itemHeight); - done(); - }); + /** + * Manually dispatch a scroll event and validate scrolled state of virtual repeat + * + * Programatically set `scrollTop` of element specified with `elementSelector` query string + * (or `#scrollContainer` by default) to be equal with its `scrollHeight` + */ + function validateScroll(virtualRepeat: VirtualRepeat, viewModel: any, done: Function, elementSelector?: string): void { + let elem = document.getElementById(elementSelector || 'scrollContainer'); + let event = new Event('scroll'); + elem.scrollTop = elem.scrollHeight; + elem.dispatchEvent(event); + window.setTimeout(() => { + window.requestAnimationFrame(() => { + validateScrolledState(virtualRepeat, viewModel, itemHeight); + done(); }); + }); } - function validateScrollUp(virtualRepeat: VirtualRepeat, viewModel: any, done: Function, element?: string) { - let elem = document.getElementById(element || 'scrollContainer'); - let event = new Event('scroll'); - elem.scrollTop = elem.scrollHeight / 2; // Scroll down but not far enough to reach bottom and call 'getNext' - elem.dispatchEvent(event); - window.setTimeout(() => { + function validateScrollUp(virtualRepeat: VirtualRepeat, viewModel: any, done: Function, element?: string): void { + let elem = document.getElementById(element || 'scrollContainer'); + let event = new Event('scroll'); + elem.scrollTop = elem.scrollHeight / 2; // Scroll down but not far enough to reach bottom and call 'getNext' + elem.dispatchEvent(event); + window.setTimeout(() => { + window.requestAnimationFrame(() => { + let eventUp = new Event('scroll'); + elem.scrollTop = 0; + elem.dispatchEvent(eventUp); + window.setTimeout(() => { window.requestAnimationFrame(() => { - let eventUp = new Event('scroll'); - elem.scrollTop = 0; - elem.dispatchEvent(eventUp); - window.setTimeout(() => { - window.requestAnimationFrame(() => { - validateScrolledState(virtualRepeat, viewModel, itemHeight); - done(); - }); - }); + validateScrolledState(virtualRepeat, viewModel, itemHeight); + done(); }); + }); }); + }); } function validatePush(virtualRepeat: VirtualRepeat, viewModel: any, done: Function) { @@ -64,44 +70,44 @@ describe('VirtualRepeat Integration', () => { function validatePop(virtualRepeat: VirtualRepeat, viewModel: any, done: Function) { viewModel.items.pop(); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.pop()); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.pop()); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => done()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.pop()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.pop()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => done()); } function validateUnshift(virtualRepeat, viewModel, done) { viewModel.items.unshift('z'); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.unshift('y', 'x')); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.unshift()); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => done()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.unshift('y', 'x')); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.unshift()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => done()); } function validateShift(virtualRepeat, viewModel, done) { viewModel.items.shift(); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.shift()); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => viewModel.items.shift()); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => done()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.shift()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => viewModel.items.shift()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => done()); } function validateReverse(virtualRepeat, viewModel, done) { viewModel.items.reverse(); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => done()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => done()); } function validateSplice(virtualRepeat: VirtualRepeat, viewModel: any, done: Function) { viewModel.items.splice(2, 1, 'x', 'y'); - nq(() => validateState(virtualRepeat, viewModel, itemHeight)); - nq(() => done()); + nq(() => validateState(virtualRepeat, viewModel, itemHeight)); + nq(() => done()); } function validateArrayChange(virtualRepeat, viewModel, done) { @@ -292,12 +298,12 @@ describe('VirtualRepeat Integration', () => { }); it('handles scrolling to bottom', done => { - containerCreate.then(() => { - validateScroll(containerVirtualRepeat, containerViewModel, () => { - expect(containerVirtualRepeat._onScroll).toHaveBeenCalled(); - done(); - }, 'scrollContainer2'); - }); + containerCreate.then(() => { + validateScroll(containerVirtualRepeat, containerViewModel, () => { + expect(containerVirtualRepeat._onScroll).toHaveBeenCalled(); + done(); + }, 'scrollContainer2'); + }); }); it('handles array changes', done => { @@ -397,39 +403,39 @@ describe('VirtualRepeat Integration', () => { beforeEach(() => { items = []; vm = { - items: items, - getNextPage: function() { - let itemLength = this.items.length; - for (let i = 0; i < 100; ++i) { - let itemNum = itemLength + i; - this.items.push('item' + itemNum); - } + items: items, + getNextPage: function () { + let itemLength = this.items.length; + for (let i = 0; i < 100; ++i) { + let itemNum = itemLength + i; + this.items.push('item' + itemNum); } + } }; nestedVm = { items: items, bar: [1], - getNextPage: function(topIndex, isAtBottom, isAtTop) { + getNextPage: function (topIndex, isAtBottom, isAtTop) { let itemLength = this.items.length; for (let i = 0; i < 100; ++i) { - let itemNum = itemLength + i; - this.items.push('item' + itemNum); + let itemNum = itemLength + i; + this.items.push('item' + itemNum); } } }; promisedVm = { - items: items, - test: '2', - getNextPage: function() { - return new Promise((resolve, reject) => { - let itemLength = this.items.length; - for (let i = 0; i < 100; ++i) { - let itemNum = itemLength + i; - this.items.push('item' + itemNum); - } - resolve(true); - }); - } + items: items, + test: '2', + getNextPage: function () { + return new Promise((resolve, reject) => { + let itemLength = this.items.length; + for (let i = 0; i < 100; ++i) { + let itemNum = itemLength + i; + this.items.push('item' + itemNum); + } + resolve(true); + }); + } }; for (let i = 0; i < 1000; ++i) { items.push('item' + i); @@ -487,48 +493,49 @@ describe('VirtualRepeat Integration', () => { }); it('handles scrolling', done => { - create.then(() => { - validateScroll(virtualRepeat, viewModel, () => { - expect(virtualRepeat._onScroll).toHaveBeenCalled(); - done(); - }); + create.then(() => { + validateScroll(virtualRepeat, viewModel, () => { + expect(virtualRepeat._onScroll).toHaveBeenCalled(); + done(); }); + }); }); + it('handles getting next data set', done => { - create.then(() => { - validateScroll(virtualRepeat, viewModel, () => { - expect(vm.getNextPage).toHaveBeenCalled(); - done(); - }); + create.then(() => { + validateScroll(virtualRepeat, viewModel, () => { + expect(vm.getNextPage).toHaveBeenCalled(); + done(); }); + }); }); it('handles getting next data set from nested function', done => { - nestedCreate.then(() => { - validateScroll(nestedVirtualRepeat, nestedViewModel, () => { - expect(nestedVm.getNextPage).toHaveBeenCalled(); - done(); - }, 'scrollContainerNested'); - }); + nestedCreate.then(() => { + validateScroll(nestedVirtualRepeat, nestedViewModel, () => { + expect(nestedVm.getNextPage).toHaveBeenCalled(); + done(); + }, 'scrollContainerNested'); + }); }); it('handles getting next data set scrolling up', done => { create.then(() => { - validateScrollUp(virtualRepeat, viewModel, () => { - let args = vm.getNextPage.calls.argsFor(0); - expect(args[0]).toEqual(0); - expect(args[1]).toBe(false); - expect(args[2]).toBe(true); - done(); - }); + validateScrollUp(virtualRepeat, viewModel, () => { + let args = vm.getNextPage.calls.argsFor(0); + expect(args[0]).toEqual(0); + expect(args[1]).toBe(false); + expect(args[2]).toBe(true); + done(); + }); }); }); it('handles getting next data set with promises', done => { - promisedCreate.then(() => { - validateScroll(promisedVirtualRepeat, promisedViewModel, () => { - // Jasmine spies seem to not be working with returned promises and getting the instance of them, causing regular checks on getNextPage to fail - expect(promisedVm.items.length).toBe(1100); - done(); - }, 'scrollContainerPromise'); - }); + promisedCreate.then(() => { + validateScroll(promisedVirtualRepeat, promisedViewModel, () => { + // Jasmine spies seem to not be working with returned promises and getting the instance of them, causing regular checks on getNextPage to fail + expect(promisedVm.items.length).toBe(1100); + done(); + }, 'scrollContainerPromise'); + }); }); it('handles getting next data set with small page size', done => { vm.items = []; @@ -542,44 +549,49 @@ describe('VirtualRepeat Integration', () => { }); }); }); - it('handles not scrolling if number of items less than elements in view', done => { - vm.items = []; - for (let i = 0; i < 5; ++i) { - vm.items.push('item' + i); - } + // The following test used to pass because there was no getMore() invoked during initialization + // so `validateScroll()` would not have been able to trigger all flow within _handleScroll of VirtualRepeat instance + // with the commit to fix issue 129, it starts to have more item and thus, scrollContainer has real scrollbar + // making synthesized scroll event in `validateScroll` work, resulting in failed test + // kept but commented out for history reason + // it('handles not scrolling if number of items less than elements in view', done => { + // vm.items = []; + // for (let i = 0; i < 5; ++i) { + // vm.items.push('item' + i); + // } + // create.then(() => { + // validateScroll(virtualRepeat, viewModel, () => { + // expect(vm.getNextPage).not.toHaveBeenCalled(); + // done(); + // }); + // }); + // }); + it('passes the current index and location state', done => { create.then(() => { validateScroll(virtualRepeat, viewModel, () => { - expect(vm.getNextPage).not.toHaveBeenCalled(); + // Taking into account 1 index difference due to default styles on browsers causing small margins of error + let args = vm.getNextPage.calls.argsFor(0); + expect(args[0]).toBeGreaterThan(988); + expect(args[0]).toBeLessThan(995); + expect(args[1]).toBe(true); + expect(args[2]).toBe(false); done(); }); }); }); - it('passes the current index and location state', done => { - create.then(() => { - validateScroll(virtualRepeat, viewModel, () => { - // Taking into account 1 index difference due to default styles on browsers causing small margins of error - let args = vm.getNextPage.calls.argsFor(0); - expect(args[0]).toBeGreaterThan(988); - expect(args[0]).toBeLessThan(995); - expect(args[1]).toBe(true); - expect(args[2]).toBe(false); - done(); - }); - }); - }); it('passes context information when using call', done => { - nestedCreate.then(() => { - validateScroll(nestedVirtualRepeat, nestedViewModel, () => { - // Taking into account 1 index difference due to default styles on browsers causing small margins of error - expect(nestedVm.getNextPage).toHaveBeenCalled(); - let scrollContext = nestedVm.getNextPage.calls.argsFor(0)[0]; - expect(scrollContext.topIndex).toBeGreaterThan(988); - expect(scrollContext.topIndex).toBeLessThan(995); - expect(scrollContext.isAtBottom).toBe(true); - expect(scrollContext.isAtTop).toBe(false); - done(); - }, 'scrollContainerNested'); - }); + nestedCreate.then(() => { + validateScroll(nestedVirtualRepeat, nestedViewModel, () => { + // Taking into account 1 index difference due to default styles on browsers causing small margins of error + expect(nestedVm.getNextPage).toHaveBeenCalled(); + let scrollContext = nestedVm.getNextPage.calls.argsFor(0)[0]; + expect(scrollContext.topIndex).toBeGreaterThan(988); + expect(scrollContext.topIndex).toBeLessThan(995); + expect(scrollContext.isAtBottom).toBe(true); + expect(scrollContext.isAtTop).toBe(false); + done(); + }, 'scrollContainerNested'); + }); }); }); }); diff --git a/test/virtual-repeat-integration.table.spec.ts b/test/virtual-repeat-integration.table.spec.ts index c8cb99f..d01da10 100644 --- a/test/virtual-repeat-integration.table.spec.ts +++ b/test/virtual-repeat-integration.table.spec.ts @@ -2,8 +2,9 @@ import './setup'; import { StageComponent, ComponentTester } from 'aurelia-testing'; import { PLATFORM } from 'aurelia-pal'; import { bootstrap } from 'aurelia-bootstrapper'; -import { createAssertionQueue, validateState, Queue } from './utilities'; +import { createAssertionQueue, validateState, Queue, validateScroll } from './utilities'; import { VirtualRepeat } from '../src/virtual-repeat'; +import { IScrollNextScrollContext } from '../src/interfaces'; PLATFORM.moduleName('src/virtual-repeat'); PLATFORM.moduleName('test/noop-value-converter'); @@ -109,6 +110,95 @@ describe('VirtualRepeat Integration', () => { queue(() => validateState(virtualRepeat, viewModel, itemHeight)); queue(() => validatePush(virtualRepeat, viewModel, done)); }); + + describe('with [infinite-scroll-next]', () => { + + beforeEach(() => { + resources.push('src/infinite-scroll-next'); + }); + + describe('invoke "_getMore()" when initial amount of items is small', () => { + + it('works with string as value of scroll-next attribute', async () => { + view = + `
+
+ + + +
\${item}
+
`; + + let called = false; + viewModel.items = createItems(5); + viewModel.getNextPage = jasmine.createSpy('getNextPage()').and.callFake( + (topIndex: number, isAtTop: boolean, isAtBottom: boolean) => { + expect(topIndex).toBe(0); + expect(isAtTop).toBe(true); + expect(isAtBottom).toBe(true); + called = true; + } + ); + + await bootstrapComponent(); + + expect(virtualRepeat['_fixedHeightContainer']).toBe(true); + expect(called).toBe(true); + expect(viewModel.getNextPage).toHaveBeenCalledTimes(1); + }); + + it('works with call binding expression', async () => { + view = + `
+ + + + +
\${item}
+
`; + + let scrollContext: IScrollNextScrollContext; + viewModel.items = createItems(5); + viewModel.getNextPage = jasmine.createSpy('getNextPage()').and.callFake(($scrollContext: IScrollNextScrollContext) => { + scrollContext = $scrollContext; + }); + + await bootstrapComponent(); + + expect(virtualRepeat['_fixedHeightContainer']).toBe(true); + expect(scrollContext).toBeDefined(); + expect(scrollContext.isAtTop).toBe(true); + expect(scrollContext.isAtBottom).toBe(true, 'Expected is at bottom to be true, recevied:' + scrollContext.isAtBottom); + expect(scrollContext.topIndex).toBe(0); + expect(viewModel.getNextPage).toHaveBeenCalledTimes(1); + }); + + it('does not work with normal binding expression', async () => { + view = + `
+ + + + +
\${item}
+
`; + + viewModel.items = createItems(5); + viewModel.getNextPage = jasmine.createSpy('getNextPage()'); + + await bootstrapComponent(); + + expect(virtualRepeat['_fixedHeightContainer']).toBe(true); + expect(viewModel.getNextPage).not.toHaveBeenCalled(); + }); + }); + }); }); function validatePush(virtualRepeat: VirtualRepeat, viewModel: any, done: Function) { @@ -124,15 +214,14 @@ describe('VirtualRepeat Integration', () => { } function validateArrayChange(virtualRepeat, viewModel, done) { - const createItems = (name: string, amount: number) => new Array(amount).map((v, index) => name + index); - viewModel.items = createItems('A', 4); + viewModel.items = createItems(4, 'A'); queue(() => validateState(virtualRepeat, viewModel, itemHeight)); - queue(() => viewModel.items = createItems('B', 0)); + queue(() => viewModel.items = createItems(0, 'B')); queue(() => validateState(virtualRepeat, viewModel, itemHeight)); - queue(() => viewModel.items = createItems('C', 101)); + queue(() => viewModel.items = createItems(101, 'C')); queue(() => validateState(virtualRepeat, viewModel, itemHeight)); - queue(() => viewModel.items = createItems('D', 0)); + queue(() => viewModel.items = createItems(0, 'D')); queue(() => validateState(virtualRepeat, viewModel, itemHeight)); queue(() => done()); } @@ -146,4 +235,8 @@ describe('VirtualRepeat Integration', () => { virtualRepeat = component.viewModel; return { virtualRepeat, viewModel, component: component }; } + + function createItems(amount: number, name: string = 'item') { + return Array.from({ length: amount }, (_, index) => name + index); + } });