diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ef5cc595b..c237963e862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,9 @@ All notable changes for each version of this project will be documented in this - The `outlet` input is now applied to the dropdown/dialog. - The dialog header no longer throws a missing locale data error for a `locale` without registered Angular locale data when `provideIgniteIntl()` is used. - The `aria-valuemin` and `aria-valuemax` of the selected seconds item no longer depend on the milliseconds of the value. +- `IgxForOfDirective` + - Growing the container of a virtualized list or grid scrolled to its end (for example by resizing the grid) no longer renders the rows or columns out of order or shifts the visible content by one item (#17575). + - In right-to-left direction, changing the items of a horizontally scrolled `igxFor` no longer moves the visible items away from the scroll position. ## 22.2.4 diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts index 99fc99dd49a..ecc9a1ded3e 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.spec.ts @@ -158,6 +158,39 @@ describe('IgxForOf directive -', () => { expect(parseInt(displayContainer.style.left, 10)).toEqual(0); }); + for (const dir of ['ltr', 'rtl']) { + it(`should keep the horizontal scroll offset if igxForOf changes while scrolled (${dir})`, () => { + const sign = dir === 'rtl' ? -1 : 1; + fix.debugElement.nativeElement.dir = dir; + fix.componentInstance.width = '500px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 200 }, + { field: '3', width: 200 }, + { field: '4', width: 200 }, + { field: '5', width: 300 } + ]; + fix.detectChanges(); + + fix.componentInstance.scrollLeft(sign * 250); + fix.detectChanges(); + const firstDir = fix.componentInstance.childVirtDirs.first; + expect(firstDir.state.startIndex).toBe(1); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + + // widening the last column keeps the scroll position and the start index + fix.componentInstance.cols = [ + ...fix.componentInstance.cols.slice(0, 4), + { field: '5', width: 400 } + ]; + fix.detectChanges(); + + expect(Math.abs(horizontalScroller.scrollLeft)).toBe(250); + expect(firstDir.state.startIndex).toBe(1); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + }); + } + it('should allow scroll in rtl direction.', () => { fix.debugElement.nativeElement.dir = 'rtl'; fix.detectChanges(); @@ -183,6 +216,40 @@ describe('IgxForOf directive -', () => { expect(state.startIndex).toBe(1); }); + for (const dir of ['ltr', 'rtl']) { + it(`should keep the scrolled content in place when growing the container prepends items (${dir})`, () => { + const sign = dir === 'rtl' ? -1 : 1; + fix.debugElement.nativeElement.dir = dir; + fix.componentInstance.width = '200px'; + fix.componentInstance.cols = [ + { field: '1', width: 100 }, + { field: '2', width: 100 }, + { field: '3', width: 100 }, + { field: '4', width: 100 }, + { field: '5', width: 100 } + ]; + fix.detectChanges(); + + fix.componentInstance.scrollLeft(sign * 250); + fix.detectChanges(); + const firstDir = fix.componentInstance.childVirtDirs.first; + expect(firstDir.state.startIndex).toBe(2); + expect(firstDir.state.chunkSize).toBe(3); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 50); + + // growing by 1px adds one item but leaves the scroll position unclamped, + // so no scroll event follows to restore the offset + fix.componentInstance.width = '201px'; + fix.detectChanges(); + + expect(Math.abs(horizontalScroller.scrollLeft)).toBe(250); + expect(firstDir.state.startIndex).toBe(1); + expect(firstDir.state.chunkSize).toBe(4); + expect(parseFloat(displayContainer.style.left)).toBe(-sign * 150); + expect(displayContainer.children[0].textContent).toBe(fix.componentInstance.data[0]['2'].toString()); + }); + } + it('should display the correct chunk items on resizing the container', async () => { // initially the container's width is narrow enough to be scrollable fix.componentInstance.width = '200px'; @@ -627,6 +694,29 @@ describe('IgxForOf directive -', () => { } }); + it('should keep rows in order when the viewport grows at the bottom', async () => { + const component = fix.componentInstance; + // scroll 25px short of the end, so that growing by 10px does not clamp the scroll position + const scrollTop = 50000 * 50 - 400 - 25; + component.scrollTop(scrollTop); + fix.detectChanges(); + await wait(); + + component.height = '410px'; + fix.detectChanges(); + await wait(); + + const rows = Array.from(displayContainer.children); + const startIndex = component.data.length - rows.length; + rows.forEach((row, index) => { + const secondCell = row.querySelector(DISPLAY_CONTAINER)?.children[1]; + expect(secondCell?.textContent) + .toBe(component.data[startIndex + index]['1'].toString()); + }); + // the rows are offset so that the content at the scroll position stays in view + expect(displayContainer.style.transform).toBe(`translateY(${startIndex * 50 - scrollTop}px)`); + }); + it('should scroll to wheel event correctly', async () => { fix.componentInstance.parentVirtDir.dc.instance._scrollInertia.smoothingDuration = 0; /* 120 is default mousewheel on Chrome, scroll 2 records down */ diff --git a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts index 74494aeebbe..59b6a31eddd 100644 --- a/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/for-of/for_of.directive.ts @@ -1486,8 +1486,14 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken this.cdr.markForCheck()); + } + } + + /** + * Creates an embedded view for the item after the current chunk and updates chunkSize. + * When the chunk already reaches the end of the data, the view is created for the item before it + * and prepended instead, so that the rendered items stay in order. + */ + protected insertChunkElem(): EmbeddedViewRef | null { if (!this.isRemote && !this.igxForOf) { - return; + return null; } + let elemIndex = this.state.startIndex! + this.state.chunkSize!; + let prepend = false; if (elemIndex >= this.igxForOf!.length) { - elemIndex = this.igxForOf!.length - this.state.chunkSize!; + if (!this.isRemote && this.state.startIndex! > 0) { + elemIndex = --this.state.startIndex!; + prepend = true; + } else { + elemIndex = this.igxForOf!.length - this.state.chunkSize!; + } } const input = this.igxForOf![elemIndex]; const embeddedView = this.dc.instance._vcr.createEmbeddedView( this._template, - new IgxForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any + this.createTemplateContext(input) as any, + { index: prepend ? 0 : undefined } ); - this._embeddedViews.push(embeddedView); + if (prepend) { + this._embeddedViews.unshift(embeddedView); + } else { + this._embeddedViews.push(embeddedView); + } this.state.chunkSize!++; + return embeddedView; + } - this._zone.run(() => this.cdr.markForCheck()); + /** + * @hidden + * Creates the template context for an item rendered by insertChunkElem(). + */ + protected createTemplateContext(input: T): IgxForOfContext { + return new IgxForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length); } /** @@ -1580,9 +1615,9 @@ export class IgxForOfDirective extends IgxForOfToken extends IgxForOfToken extends IgxForOfDirec } protected override addLastElem() { - let elemIndex = this.state.startIndex! + this.state.chunkSize!; - if (!this.isRemote && !this.igxForOf) { - return; + const embeddedView = this.insertChunkElem(); + if (embeddedView) { + this.subscribeToViewObserver(this.getViewObservedNode(embeddedView)); } + } - if (elemIndex >= this.igxForOf!.length) { - elemIndex = this.igxForOf!.length - this.state.chunkSize!; - } - const input = this.igxForOf![elemIndex]; - const embeddedView = this.dc.instance._vcr.createEmbeddedView( - this._template, - new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length) as any - ); - - this._embeddedViews.push(embeddedView); - this.subscribeToViewObserver(this.getViewObservedNode(embeddedView)); - this.state.chunkSize!++; + protected override createTemplateContext(input: T): IgxGridForOfContext { + return new IgxGridForOfContext(input, this.igxForOf!, this.getContextIndex(input), this.igxForOf!.length); } protected _updateViews(prevChunkSize: number | undefined) { diff --git a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts index 95aa2b988f9..5317f45cda2 100644 --- a/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid.component.spec.ts @@ -203,6 +203,39 @@ describe('IgxGrid Component Tests #grid', () => { expect(grid.columns[1].field).toBe('index'); }); + it('should preserve row ordering when the viewport grows at the bottom', async () => { + const fix = TestBed.createComponent(IgxGridTestComponent); + fix.componentInstance.data = Array.from({ length: 1000 }, (_, index) => ({ index, value: index })); + fix.detectChanges(); + fix.detectChanges(); + + const grid = fix.componentInstance.grid; + const lastIndex = fix.componentInstance.data.length - 1; + + grid.verticalScrollContainer.scrollTo(lastIndex); + await wait(100); + + grid.height = '200px'; + fix.detectChanges(); + await wait(100); + + grid.height = '600px'; + fix.detectChanges(); + await wait(100); + + grid.verticalScrollContainer.scrollTo(lastIndex); + await wait(100); + + const renderedIndexes = grid.rowList.toArray().map(row => row.data.index); + expect(renderedIndexes[renderedIndexes.length - 1]).toBe(lastIndex); + renderedIndexes.forEach((index, position) => { + if (position > 0) { + expect(index).toBe(renderedIndexes[position - 1] + 1); + } + }); + fix.destroy(); + }); + it('should initialize grid with remote virtualization', async () => { const fix = TestBed.createComponent(IgxGridRemoteVirtualizationComponent); fix.detectChanges();