diff --git a/CHANGELOG.md b/CHANGELOG.md index 928db2f64f7..d97cf80ce66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes for each version of this project will be documented in this ## 22.2.0 +### General + +- The Excel style filtering search list, `IgxComboComponent` and `IgxSimpleComboComponent` are now virtualized by `IgxVirtualScrollComponent` instead of the `igxFor` directive. A row is measured in the DOM once it renders and the measured size replaces the estimate it started from; rows that have not rendered keep that estimate. + - The list markup changed accordingly: `igx-display-container` and the `igx-vhelper--vertical` scrollbar are replaced by the `igx-virtual-scroll` host and its `igx-vs__item` row wrappers. Applications and tests that reach into those elements directly need updating. + - `IgxComboComponent.virtualScrollContainer` and `IgxSimpleComboComponent.virtualScrollContainer` are marked `@hidden @internal`; their concrete type follows the engine the combo uses. + - `IgxDropDownComponent` accepts a content-projected `igx-virtual-scroll` in addition to `*igxFor`, which keeps working as documented. Selection and navigation behave the same either way. + ### New Features +- `IgxVirtualScrollComponent` + - Added `initialViewportSize`, the viewport size to render the first window against. A list that is hidden until the change detection pass that reveals it has no size to measure in that pass and would render nothing; this gives that first render a size to work from, and the host's own size takes over once it has been laid out. + - Added `dataWindow`, taking a loaded page of a larger collection as `{ items, startIndex, totalCount }`. The list is as long as `totalCount`, so the scrollbar spans the whole collection while only the page is in memory, and indices the page does not cover render nothing until a page that covers them arrives. `data` is unchanged and is used whenever `dataWindow` is not set. - `IgxChipComponent` - Added the `outlined` property to the component. When set to `true`, the Chip will have an outlined style. diff --git a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts index 4c3c5ac884f..e2cd1797223 100644 --- a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts @@ -33,14 +33,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I /** @hidden @internal */ public override get scrollContainer(): HTMLElement { - // TODO: Update, use public API if possible: - return this.virtDir.dc.location.nativeElement; - } - - protected get isScrolledToLast(): boolean { - const scrollTop = this.virtDir.scrollPosition; - const scrollHeight = this.virtDir.getScroll()!.scrollHeight; - return Math.floor(scrollTop + this.virtDir.igxForContainerSize) === scrollHeight; + return this.virtualization!.scrollElement; } protected get lastVisibleIndex(): number { @@ -137,7 +130,11 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I * @hidden */ public override navigateFirst() { - this.navigateItem(this.virtDir.igxForOf!.findIndex(e => !e?.isHeader)); + // The first selectable entry can only be looked for in what is loaded. A page that + // starts further in does not hold it, so the collection's own start is the target. + this.navigateItem(this.virtualization?.startIndex === 0 + ? this.virtualization.findIndex(e => !e?.isHeader) + : 0); this.combo.setActiveDescendant(); } @@ -145,7 +142,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I * @hidden */ public override navigatePrev() { - if (this._focusedItem && this._focusedItem.index === 0 && this.virtDir.state.startIndex === 0) { + if (this._focusedItem && this._focusedItem.index === 0) { this.combo.focusSearchInput(false); this.focusedItem = null; } else { @@ -159,7 +156,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I * @hidden */ public override navigateNext() { - const lastIndex = this.combo.totalItemCount ? this.combo.totalItemCount - 1 : this.virtDir.igxForOf!.length - 1; + const lastIndex = (this.virtualization?.length ?? 0) - 1; if (this._focusedItem && this._focusedItem.index === lastIndex) { this.focusAddItemButton(); } else { @@ -185,7 +182,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I * @hidden @internal */ public override updateScrollPosition() { - this.virtDir.getScroll()!.scrollTop = this._scrollPosition; + this.virtualization!.scrollPosition = this._scrollPosition; } /** @@ -208,14 +205,15 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I } public override ngAfterViewInit() { - this.virtDir.getScroll()!.addEventListener('scroll', this.scrollHandler); + super.ngAfterViewInit(); + this.scrollContainer.addEventListener('scroll', this.scrollHandler); } /** * @hidden @internal */ public override ngOnDestroy(): void { - this.virtDir.getScroll()!.removeEventListener('scroll', this.scrollHandler); + this.virtualization?.scrollElement.removeEventListener('scroll', this.scrollHandler); super.ngOnDestroy(); } diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index 0d61396c6af..85551cc8333 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -44,7 +44,8 @@ import { getCurrentResourceStrings, onResourceChangeHandle } from 'igniteui-angular/core'; -import { IForOfState, IgxForOfDirective } from 'igniteui-angular/directives'; +import { IForOfState } from 'igniteui-angular/directives'; +import { IgxVirtualScrollComponent, VirtualScrollState } from 'igniteui-angular/virtual-scroll'; import { IgxIconService } from 'igniteui-angular/icon'; import { IGX_INPUT_GROUP_TYPE, IgxInputDirective, IgxInputGroupComponent, IgxInputGroupType, IgxInputState, IgxHintDirective, IgxLabelDirective, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; import { IgxComboDropDownComponent } from './combo-dropdown.component'; @@ -90,6 +91,9 @@ export interface IgxComboBase { let NEXT_ID = 0; +/** Row height assumed before a real row has been measured, in pixels. */ +const DEFAULT_ITEM_SIZE = 40; + /** @hidden @internal */ export const enum DataTypes { @@ -768,11 +772,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh public searchInput: ElementRef = null!; /** @hidden @internal */ - @ViewChild(IgxForOfDirective, { static: true }) - public virtualScrollContainer!: IgxForOfDirective; - - @ViewChild(IgxForOfDirective, { read: IgxForOfDirective, static: true }) - protected virtDir!: IgxForOfDirective; + @ViewChild('virtualScroll', { static: true }) + public virtualScrollContainer!: IgxVirtualScrollComponent; @ViewChild('dropdownItemContainer', { static: true }) protected dropdownContainer: ElementRef = null!; @@ -877,7 +878,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh * ``` */ public get virtualizationState(): IForOfState { - return this.virtDir.state; + return this._virtualizationState; } /** * Sets the current state of the virtualized data. @@ -888,7 +889,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh * ``` */ public set virtualizationState(state: IForOfState) { - this.virtDir.state = state; + this._virtualizationState = { ...state }; + void this.virtualScrollContainer?.scrollToIndex(state.startIndex ?? 0); } /** @@ -911,7 +913,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh * ``` */ public get totalItemCount(): number { - return this.virtDir.totalItemCount; + return this._totalItemCount; } /** * Sets total count of the virtual data items, when using remote service. @@ -922,7 +924,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh * ``` */ public set totalItemCount(count: number) { - this.virtDir.totalItemCount = count; + this._totalItemCount = count; } /** @hidden @internal */ @@ -968,8 +970,11 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh this._filteringOptions = value; } - protected containerSize: number | undefined = undefined; - protected itemSize = undefined; + protected itemSize: number | undefined = undefined; + + /** The window the list renders, in the shape `virtualizationState` and `dataPreLoad` use. */ + private _virtualizationState: IForOfState = { startIndex: 0, chunkSize: 0 }; + private _totalItemCount = 0; protected _data: any[] = []; protected _value: any[] = []; protected _displayValue = ''; @@ -1063,22 +1068,44 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh this.manageRequiredAsterisk(); this.cdr.detectChanges(); } - this.virtDir.chunkPreload.pipe(takeUntil(this.destroy$)).subscribe((e: IForOfState) => { - const eventArgs: IForOfState = Object.assign({}, e, { owner: this }); - this.dataPreLoad.emit(eventArgs); - }); this.dropdown?.opening.subscribe((_args: IBaseCancelableBrowserEventArgs) => { - // calculate the container size and item size based on the sizes from the DOM - const dropdownContainerHeight = this.dropdownContainer.nativeElement.getBoundingClientRect().height; - if (dropdownContainerHeight) { - this.containerSize = parseFloat(dropdownContainerHeight); - } + // Take the row height from a real item, for the combos that do not set itemHeight. if (this.dropdown.children?.first) { this.itemSize = this.dropdown.children.first.element.nativeElement.getBoundingClientRect().height; } }); } + /** @hidden @internal The height the list gets, for the pass that opens the drop-down. */ + protected get viewportSize(): number { + return this.itemsMaxHeight || this.estimatedItemSize * this.itemsInContainer; + } + + /** @hidden @internal The size rows are assumed to be until they are measured. */ + protected get estimatedItemSize(): number { + return this.itemHeight || this.itemSize || DEFAULT_ITEM_SIZE; + } + + /** @hidden @internal Where the loaded items sit in the collection they came from. */ + protected get virtualStartIndex(): number { + return this._virtualizationState.startIndex ?? 0; + } + + /** + * @hidden @internal + * Reports the rendered window as `virtualizationState` and asks for the data behind it. + */ + public handleVirtualStateChange(state: VirtualScrollState): void { + const chunkSize = state.endIndex - state.startIndex + 1; + if (this._virtualizationState.startIndex === state.startIndex && + this._virtualizationState.chunkSize === chunkSize) { + return; + } + + this._virtualizationState = { startIndex: state.startIndex, chunkSize }; + this.dataPreLoad.emit({ ...this._virtualizationState, owner: this }); + } + /** @hidden @internal */ public ngOnDestroy(): void { this.destroy$.next(); @@ -1202,7 +1229,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh this.customValueFlag = false; this.searchInput?.nativeElement.focus(); this.dropdown.focusedItem = null; - this.virtDir.scrollTo(0); + void this.virtualScrollContainer?.scrollToIndex(0); } /** @hidden @internal */ @@ -1219,14 +1246,35 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh owner: this, cancel: false }; + const restore = this.resetVirtualizationState(); this.searchInputUpdate.emit(args); + if (args.cancel) { this.filterValue = null!; + restore(); + } else { + void this.virtualScrollContainer?.scrollToIndex(0); } } this.checkMatch(); } + /** + * @hidden @internal + * Reports the start of the list without moving it. Returns a callback that puts it back. + */ + private resetVirtualizationState(): () => void { + const previous = this._virtualizationState; + if (previous.startIndex === 0) { + return () => { }; + } + + this._virtualizationState = { startIndex: 0, chunkSize: previous.chunkSize }; + return () => { + this._virtualizationState = previous; + }; + } + /** * Event handlers * diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.html b/projects/igniteui-angular/combo/src/combo/combo.component.html index 31c42418165..0a5a01e1d7b 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.html +++ b/projects/igniteui-angular/combo/src/combo/combo.component.html @@ -70,28 +70,37 @@ } + @let itemWindow = data! + | comboFiltering:filterValue:displayKey:filteringOptions:filterFunction:disableFiltering + | comboGrouping:groupKey:valueKey:groupSortingDirection:compareCollator + | comboDataWindow:totalItemCount:virtualStartIndex;
- - @if (item?.isHeader) { - - - } - - @if (!item?.isHeader) { - - - } - + + + + @if (item?.isHeader) { + + + } + + @if (!item?.isHeader) { + + + } + + +
@if (filteredData?.length === 0 || isAddButtonVisible()) { @@ -105,7 +114,7 @@ @if (isAddButtonVisible()) { + [attr.aria-label]="resourceStrings.igx_combo_addCustomValues_placeholder" [index]="itemWindow.totalCount"> diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts index 20ed6074bab..48d7baaa504 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -6,7 +6,7 @@ import { } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs'; +import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { IgxSelectionAPIService } from 'igniteui-angular/core'; import { IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; import { SortingDirection } from '../../../core/src/data-operations/sorting-strategy'; @@ -33,7 +33,7 @@ const CSS_CLASS_COMBO_DROPDOWN = 'igx-combo__drop-down'; const CSS_CLASS_DROPDOWN = 'igx-drop-down'; const CSS_CLASS_DROPDOWNLIST_SCROLL = 'igx-drop-down__list-scroll'; const CSS_CLASS_CONTENT = 'igx-combo__content'; -const CSS_CLASS_CONTAINER = 'igx-display-container'; +const CSS_CLASS_CONTAINER = 'igx-vs__content'; const CSS_CLASS_DROPDOWNLISTITEM = 'igx-drop-down__item'; const CSS_CLASS_TOGGLEBUTTON = 'igx-combo__toggle-button'; const CSS_CLASS_CLEARBUTTON = 'igx-combo__clear-button'; @@ -41,7 +41,7 @@ const CSS_CLASS_ADDBUTTON = 'igx-combo__add-item'; const CSS_CLASS_SELECTED = 'igx-drop-down__item--selected'; const CSS_CLASS_FOCUSED = 'igx-drop-down__item--focused'; const CSS_CLASS_HEADERITEM = 'igx-drop-down__header'; -const CSS_CLASS_SCROLLBAR_VERTICAL = 'igx-vhelper--vertical'; +const CSS_CLASS_SCROLLBAR_VERTICAL = 'igx-virtual-scroll'; const CSS_CLASS_INPUTGROUP = 'igx-input-group'; const CSS_CLASS_COMBO_INPUTGROUP = 'igx-input-group__input'; const CSS_CLASS_INPUTGROUP_BUNDLE = 'igx-input-group__bundle'; @@ -806,7 +806,7 @@ describe('igxCombo', () => { }); it('should allow canceling and overwriting of item addition', fakeAsync(() => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - const mockVirtDir = jasmine.createSpyObj('virtDir', ['scrollTo']); + const mockScroll = jasmine.createSpyObj('virtualScroll', { scrollToIndex: Promise.resolve() }); const mockInput = jasmine.createSpyObj('mockInput', [], { nativeElement: jasmine.createSpyObj('mockElement', ['focus']) }); @@ -829,7 +829,7 @@ describe('igxCombo', () => { combo.data = ['Item 1', 'Item 2', 'Item 3']; combo.dropdown = dropdown; combo.searchInput = mockInput; - (combo as any).virtDir = mockVirtDir; + (combo as any).virtualScrollContainer = mockScroll; let mockAddParams: IComboItemAdditionEvent = { cancel: false, owner: combo, @@ -847,7 +847,7 @@ describe('igxCombo', () => { expect(combo.data.length).toEqual(4); expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); expect(combo.addition.emit).toHaveBeenCalledTimes(1); - expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(1); + expect(mockScroll.scrollToIndex).toHaveBeenCalledTimes(1); expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(1); expect(combo.data[combo.data.length - 1]).toBe('Item 99'); expect(selectionService.get(combo.id).size).toBe(1); @@ -868,7 +868,7 @@ describe('igxCombo', () => { tick(); expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); expect(combo.addition.emit).toHaveBeenCalledTimes(2); - expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(1); + expect(mockScroll.scrollToIndex).toHaveBeenCalledTimes(1); expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(1); expect(combo.data.length).toEqual(4); expect(combo.data[combo.data.length - 1]).toBe('Item 99'); @@ -891,7 +891,7 @@ describe('igxCombo', () => { tick(); expect(combo.addition.emit).toHaveBeenCalledWith(mockAddParams); expect(combo.addition.emit).toHaveBeenCalledTimes(3); - expect(mockVirtDir.scrollTo).toHaveBeenCalledTimes(2); + expect(mockScroll.scrollToIndex).toHaveBeenCalledTimes(2); expect(combo.searchInput.nativeElement.focus).toHaveBeenCalledTimes(2); expect(combo.data.length).toEqual(5); expect(combo.data[combo.data.length - 1]).toBe(subParams.newValue); @@ -1088,7 +1088,8 @@ describe('igxCombo', () => { const checkGroupedItemsClass = () => { fixture.detectChanges(); dropdownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; - dropdownItems = dropdownContainer.children; + dropdownItems = dropdownContainer.querySelectorAll('igx-combo-item'); + expect(dropdownItems.length).toBeGreaterThan(0); Array.from(dropdownItems).forEach((item) => { const itemElement = item as HTMLElement; const hasClass = itemElement.classList.contains(CSS_CLASS_DROPDOWNLISTITEM) || @@ -1102,9 +1103,8 @@ describe('igxCombo', () => { // Scroll through the list in chunks and verify items for (let scrollIndex = 10; scrollIndex < combo.data.length; scrollIndex += 10) { - combo.virtualScrollContainer.scrollTo(scrollIndex); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); - await wait(30); + await combo.virtualScrollContainer.scrollToIndex(scrollIndex); + await combo.virtualScrollContainer.layoutComplete; checkGroupedItemsClass(); } }); @@ -1408,7 +1408,7 @@ describe('igxCombo', () => { const verifyComboData = () => { fixture.detectChanges(); - let ind = combo.virtualScrollContainer.state.startIndex; + let ind = combo.virtualizationState.startIndex; for (let itemIndex = 0; itemIndex < 10; itemIndex++) { expect(combo.data[itemIndex].id).toEqual(ind); expect(combo.data[itemIndex].product).toEqual('Product ' + ind); @@ -1424,32 +1424,31 @@ describe('igxCombo', () => { verifyComboData(); expect(combo.virtualizationState.startIndex).toEqual(productIndex); + const expectIndexInWindow = (index: number) => { + const { startIndex, chunkSize } = combo.virtualizationState; + expect(index).toBeGreaterThanOrEqual(startIndex); + expect(index).toBeLessThanOrEqual(startIndex + chunkSize - 1); + }; + productIndex = 42; - combo.virtualScrollContainer.scrollTo(productIndex); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(productIndex); fixture.detectChanges(); verifyComboData(); - // index is at bottom - expect(combo.virtualizationState.startIndex + combo.virtualizationState.chunkSize - 1) - .toEqual(productIndex); + expectIndexInWindow(productIndex); productIndex = 485; - combo.virtualScrollContainer.scrollTo(productIndex); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(productIndex); fixture.detectChanges(); verifyComboData(); - expect(combo.virtualizationState.startIndex + combo.virtualizationState.chunkSize - 1) - .toEqual(productIndex); + expectIndexInWindow(productIndex); productIndex = 873; - combo.virtualScrollContainer.scrollTo(productIndex); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(productIndex); fixture.detectChanges(); verifyComboData(); productIndex = 649; - combo.virtualScrollContainer.scrollTo(productIndex); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(productIndex); fixture.detectChanges(); verifyComboData(); }); @@ -1471,8 +1470,7 @@ describe('igxCombo', () => { expect(combo.displayValue).toEqual(`${selectedItems[0][combo.displayKey]}, ${selectedItems[1][combo.displayKey]}`); // Scroll selected items out of view - combo.virtualScrollContainer.scrollTo(40); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(40); fixture.detectChanges(); combo.handleClearItems(spyObj); expect(combo.selection).toEqual([]); @@ -1491,17 +1489,20 @@ describe('igxCombo', () => { expect(combo.selection.length).toEqual(2); expect(combo.value.length).toEqual(2); - const firstItem = combo.data[combo.data.length - 1]; + const loaded = (id: number) => combo.data.find(item => item[combo.valueKey] === id); + + const firstItem = loaded(9); + expect(firstItem).toBeDefined(); expect(combo.displayValue).toEqual(firstItem[combo.displayKey]); combo.toggle(); // scroll to second selected item - combo.virtualScrollContainer.scrollTo(19); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(19); fixture.detectChanges(); - const secondItem = combo.data[combo.data.length - 1]; + const secondItem = loaded(19); + expect(secondItem).toBeDefined(); expect(combo.displayValue).toEqual(`${firstItem[combo.displayKey]}, ${secondItem[combo.displayKey]}`); }); it('should fire selectionChanging event with partial data for items out of view', async () => { @@ -1526,26 +1527,107 @@ describe('igxCombo', () => { expect(selectionSpy).toHaveBeenCalledWith(expectedResults); // Scroll selected items out of view - combo.virtualScrollContainer.scrollTo(40); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(40); fixture.detectChanges(); - combo.select([combo.data[0][valueKey], combo.data[1][valueKey]]); + + // Whichever page the list has landed on, its first two records are the ones + // being added; the two selected before it are now out of view and partial. + const added = [combo.data[0], combo.data[1]]; + const partial = [{ [valueKey]: 0 }, { [valueKey]: 1 }]; + combo.select([added[0][valueKey], added[1][valueKey]]); + Object.assign(expectedResults, { - newValue: [0, 1, 31, 32], + newValue: [0, 1, added[0][valueKey], added[1][valueKey]], oldValue: [0, 1], - newSelection: [{ [valueKey]: 0 }, { [valueKey]: 1 }, combo.data[0], combo.data[1]], - oldSelection: [{ [valueKey]: 0 }, { [valueKey]: 1 }], - added: [combo.data[0], combo.data[1]], + newSelection: [...partial, ...added], + oldSelection: partial, + added, removed: [], event: undefined, owner: combo, - displayText: `Product 0, Product 1, Product 31, Product 32`, + displayText: `Product 0, Product 1, ${added[0][combo.displayKey]}, ${added[1][combo.displayKey]}`, cancel: false }); expect(selectionSpy).toHaveBeenCalledWith(expectedResults); }); }); + describe('Binding to remote data with request cancellation: ', () => { + let host: IgxComboDeferredRemoteComponent; + + const settle = async () => { + fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + fixture.detectChanges(); + }; + + beforeEach(async () => { + fixture = TestBed.createComponent(IgxComboDeferredRemoteComponent); + fixture.detectChanges(); + host = fixture.componentInstance; + combo = host.instance; + + // The first page, so the list starts from a loaded window. + host.service.complete(host.service.requests[0]); + await settle(); + }); + + it('should keep the latest page after the previous request is cancelled', async () => { + combo.toggle(); + await settle(); + + // A: scrolled to one window, its request left unanswered. + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + const requestA = host.service.requests[host.service.requests.length - 1]; + expect(requestA.state.startIndex).toBeGreaterThan(0); + + // B: scrolled somewhere else, which drops the request still in flight. + await combo.virtualScrollContainer.scrollToIndex(800); + await settle(); + const requestB = host.service.requests[host.service.requests.length - 1]; + + expect(requestB).not.toBe(requestA); + expect(requestA.subject.observed).toBeFalse(); + expect(requestB.subject.observed).toBeTrue(); + + host.service.complete(requestB); + await settle(); + + const rangeOf = (state: IForOfState) => ({ + start: state.startIndex, + end: state.startIndex + (state.chunkSize || 10) - 1 + }); + const rowText = () => Array.from( + fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement + .querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`) + ).map((row: HTMLElement) => row.textContent.trim()); + + const windowB = rangeOf(requestB.state); + expect(combo.virtualizationState.startIndex).toEqual(requestB.state.startIndex); + expect(combo.data[0].id).toEqual(windowB.start); + expect(combo.data.every(record => + record.id >= windowB.start && record.id <= windowB.end)).toBeTrue(); + + const renderedAfterB = rowText(); + expect(renderedAfterB.length).toBeGreaterThan(0); + renderedAfterB.forEach(text => { + const id = Number(text.replace('Product ', '')); + expect(id).toBeGreaterThanOrEqual(windowB.start); + expect(id).toBeLessThanOrEqual(windowB.end); + }); + + // Emitting from the cancelled request must not affect the bound data or rows. + host.service.complete(requestA); + await settle(); + + expect(combo.virtualizationState.startIndex).toEqual(requestB.state.startIndex); + expect(combo.data[0].id).toEqual(windowB.start); + expect(rowText()).toEqual(renderedAfterB); + }); + }); + describe('Binding to ngModel tests: ', () => { let component: ComboModelBindingComponent; beforeEach(() => { @@ -1649,8 +1731,7 @@ describe('igxCombo', () => { await wait(); fixture.detectChanges(); expect(combo.collapsed).toBeFalsy(); - combo.virtualScrollContainer.scrollTo(51); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(51); fixture.detectChanges(); const items = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`)); const lastItem = items[items.length - 1].componentInstance; @@ -1670,7 +1751,7 @@ describe('igxCombo', () => { combo.searchValue = 'New'; combo.handleInputChange(); fixture.detectChanges(); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; const addItemButton = fixture.debugElement.query(By.directive(IgxComboAddItemComponent)); addItemButton.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); fixture.detectChanges(); @@ -1708,7 +1789,8 @@ describe('igxCombo', () => { dropdown.toggle(); fixture.detectChanges(); expect(dropdown.items).toBeDefined(); - expect(dropdown.items.length).toEqual(5); + expect(dropdown.items.length).toBeGreaterThan(0); + expect(dropdown.items.length).toBeLessThan(combo.data.length); dropdown.onFocus(); expect(dropdown.focusedItem).toEqual(dropdown.items[0]); expect(dropdown.focusedItem.focused).toEqual(true); @@ -1797,50 +1879,59 @@ describe('igxCombo', () => { tick(); expect(combo.close).toHaveBeenCalledTimes(2); })); - it('should select/focus dropdown list items with space/up and down arrow keys', () => { + it('should select/focus dropdown list items with space/up and down arrow keys', async () => { let selectedItemsCount = 0; combo.toggle(); fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; + fixture.detectChanges(); const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; - const dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + const rowAt = (index: number) => + dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`)[index]; let focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); let selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); expect(focusedItems.length).toEqual(0); expect(selectedItems.length).toEqual(0); - const focusAndVerifyItem = (itemIndex: number, key: string) => { + const focusAndVerifyItem = async (itemIndex: number, key: string) => { UIInteractions.triggerEventHandlerKeyDown(key, dropdownContent); + // This fixture uses manual change detection; render the state + // updated by the keyboard event. + fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); expect(focusedItems.length).toEqual(1); - expect(focusedItems[0]).toEqual(dropdownItems[itemIndex]); + expect(focusedItems[0]).toEqual(rowAt(itemIndex)); }; - const selectAndVerifyItem = (itemIndex: number) => { + const selectAndVerifyItem = async (itemIndex: number) => { UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); fixture.detectChanges(); + await combo.virtualScrollContainer.layoutComplete; + fixture.detectChanges(); selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); expect(selectedItems.length).toEqual(selectedItemsCount); - expect(selectedItems).toContain(dropdownItems[itemIndex]); + expect(selectedItems).toContain(rowAt(itemIndex)); }; - focusAndVerifyItem(0, 'ArrowDown'); + await focusAndVerifyItem(0, 'ArrowDown'); selectedItemsCount++; - selectAndVerifyItem(0); + await selectAndVerifyItem(0); for (let index = 1; index < 5; index++) { - focusAndVerifyItem(index, 'ArrowDown'); + await focusAndVerifyItem(index, 'ArrowDown'); } selectedItemsCount++; - selectAndVerifyItem(4); + await selectAndVerifyItem(4); for (let index = 3; index >= 2; index--) { - focusAndVerifyItem(index, 'ArrowUp'); + await focusAndVerifyItem(index, 'ArrowUp'); } selectedItemsCount++; - selectAndVerifyItem(2); + await selectAndVerifyItem(2); }); it('should properly navigate using HOME/END key', (async () => { let firstVisibleItem: Element; @@ -1852,14 +1943,14 @@ describe('igxCombo', () => { expect(scrollbar.scrollTop).toEqual(0); // Scroll to bottom; UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); // Content was scrolled to bottom expect(scrollbar.scrollHeight - scrollbar.scrollTop - scrollbar.clientHeight).toBeLessThan(1); // Scroll to top UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); const dropdownContainer: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); @@ -2019,14 +2110,14 @@ describe('igxCombo', () => { expect(scrollbar.scrollTop).toEqual(0); // Scroll to bottom; UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); // Content was scrolled to bottom expect(scrollbar.scrollHeight - scrollbar.scrollTop - scrollbar.clientHeight).toBeLessThan(1); // Scroll to top UIInteractions.triggerEventHandlerKeyDown('Home', dropdownContent); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); const dropdownContainer: HTMLElement = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; firstVisibleItem = dropdownContainer.querySelector(`.${CSS_CLASS_DROPDOWNLISTITEM}` + ':first-child'); @@ -2066,7 +2157,7 @@ describe('igxCombo', () => { expect(mockFunc).toBeDefined(); }); it('should restore position of dropdown scroll after opening', async () => { - const virtDir = combo.virtualScrollContainer; + const scroller = () => fixture.debugElement.query(By.css('igx-virtual-scroll')).nativeElement; spyOn(combo.dropdown, 'onToggleOpening').and.callThrough(); spyOn(combo.dropdown, 'onToggleOpened').and.callThrough(); spyOn(combo.dropdown, 'onToggleClosing').and.callThrough(); @@ -2077,14 +2168,14 @@ describe('igxCombo', () => { expect(combo.collapsed).toEqual(false); expect(combo.dropdown.onToggleOpening).toHaveBeenCalledTimes(1); expect(combo.dropdown.onToggleOpened).toHaveBeenCalledTimes(1); - let vContainerScrollHeight = virtDir.getScroll().scrollHeight; - expect(virtDir.getScroll().scrollTop).toEqual(0); + let vContainerScrollHeight = scroller().scrollHeight; + expect(scroller().scrollTop).toEqual(0); const itemHeight = parseFloat(combo.dropdown.children.first.element.nativeElement.getBoundingClientRect().height); expect(vContainerScrollHeight).toBeGreaterThan(itemHeight); - virtDir.getScroll().scrollTop = Math.floor(vContainerScrollHeight / 2); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + scroller().scrollTop = Math.floor(vContainerScrollHeight / 2); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); - expect(virtDir.getScroll().scrollTop).toBeGreaterThan(0); + expect(scroller().scrollTop).toBeGreaterThan(0); UIInteractions.simulateClickEvent(document.documentElement); await wait(); fixture.detectChanges(); @@ -2097,8 +2188,8 @@ describe('igxCombo', () => { expect(combo.collapsed).toEqual(false); expect(combo.dropdown.onToggleOpening).toHaveBeenCalledTimes(2); expect(combo.dropdown.onToggleOpened).toHaveBeenCalledTimes(2); - vContainerScrollHeight = virtDir.getScroll().scrollHeight; - expect(virtDir.getScroll().scrollTop).toEqual(vContainerScrollHeight / 2); + vContainerScrollHeight = scroller().scrollHeight; + expect(scroller().scrollTop).toEqual(vContainerScrollHeight / 2); }); it('should display vertical scrollbar properly', async () => { combo.toggle(); @@ -2125,8 +2216,7 @@ describe('igxCombo', () => { const scrollbar = fixture.debugElement.query(By.css(`.${CSS_CLASS_SCROLLBAR_VERTICAL}`)).nativeElement as HTMLElement; expect(scrollbar.scrollTop).toEqual(0); - combo.virtualScrollContainer.scrollTo(12); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(12); fixture.detectChanges(); let selectedItem = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_DROPDOWNLISTITEM}`))[1]; selectedItem.triggerEventHandler('click', UIInteractions.getMouseEvent('click')); @@ -2136,13 +2226,12 @@ describe('igxCombo', () => { const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); UIInteractions.triggerEventHandlerKeyDown('End', dropdownContent); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.layoutComplete; fixture.detectChanges(); // Content was scrolled to bottom expect(scrollbar.scrollHeight - scrollbar.scrollTop - scrollbar.clientHeight).toBeLessThan(1); - combo.virtualScrollContainer.scrollTo(4); - await firstValueFrom(combo.virtualScrollContainer.chunkLoad); + await combo.virtualScrollContainer.scrollToIndex(4); fixture.detectChanges(); selectedItem = fixture.debugElement.query(By.css(`.${CSS_CLASS_SELECTED}`)); expect(selectedItem.nativeElement.textContent).toEqual(selectedItemText); @@ -2730,20 +2819,21 @@ describe('igxCombo', () => { combo.toggle(); await wait(); fixture.detectChanges(); - let headers = combo.dropdown.headers.map(header => header.element.nativeElement.textContent?.trim()); - expect(headers).toEqual(['Ángel', 'Boris', 'México']); + + const groupOrder = () => combo.virtualScrollContainer.dataWindow()!.items + .filter((item: any) => item?.isHeader) + .map((item: any) => item[combo.groupKey]); + + // All four groups, not the three the viewport used to happen to show. + expect(groupOrder()).toEqual(['Ángel', 'Boris', 'México', 'Méxícó']); combo.groupSortingDirection = SortingDirection.Desc; - combo.toggle(); fixture.detectChanges(); - headers = combo.dropdown.headers.map(header => header.element.nativeElement.textContent?.trim()); - expect(headers).toEqual(['Méxícó', 'México', 'Boris']); + expect(groupOrder()).toEqual(['Méxícó', 'México', 'Boris', 'Ángel']); combo.groupSortingDirection = SortingDirection.None; - combo.toggle(); fixture.detectChanges(); - headers = combo.dropdown.headers.map(header => header.element.nativeElement.textContent?.trim()); - expect(headers).toEqual(['Méxícó', 'Ángel', 'México']); + expect(groupOrder()).toEqual(['Méxícó', 'Ángel', 'México', 'Boris']); }); }); describe('Filtering tests: ', () => { @@ -2924,24 +3014,40 @@ describe('igxCombo', () => { tick(); fixture.detectChanges(); const searchInput = fixture.debugElement.query(By.css('input[name=\'searchInput\']')); - const verifyFilteredItems = (inputValue: string, expectedItemsNumber) => { + const verifyFilteredItems = (inputValue: string) => { UIInteractions.triggerInputEvent(searchInput, inputValue); fixture.detectChanges(); + + const matches = combo.data.filter(item => + item.field.toLowerCase().includes(inputValue.toLowerCase())); + expect(combo.filteredData).toEqual(matches); + dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); - expect(dropdownItems.length).toEqual(expectedItemsNumber); + + // The DOM holds the window over that collection, so every row it renders + // has to be one of the matches; how many of them fit is the viewport's business. + if (matches.length === 0) { + expect(dropdownItems.length).toEqual(0); + } else { + expect(dropdownItems.length).toBeGreaterThan(0); + Array.from(dropdownItems).forEach((row: HTMLElement) => { + const text = row.textContent.trim(); + expect(matches.some(m => text.includes(m.field))).toBeTrue(); + }); + } }; - verifyFilteredItems('M', 4); + verifyFilteredItems('M'); - verifyFilteredItems('Mi', 3); + verifyFilteredItems('Mi'); expectedValues = expectedValues.filter(data => data.field.toLowerCase().includes('mi')); checkFilteredItems(dropdownItems); - verifyFilteredItems('Mis', 2); + verifyFilteredItems('Mis'); expectedValues = expectedValues.filter(data => data.field.toLowerCase().includes('mis')); checkFilteredItems(dropdownItems); - verifyFilteredItems('Mist', 0); + verifyFilteredItems('Mist'); })); it('should display empty list when the search query does not match any item', () => { let dropDownContainer: HTMLElement; @@ -2993,20 +3099,21 @@ describe('igxCombo', () => { fixture.detectChanges(); const searchInput = fixture.debugElement.query(By.css(CSS_CLASS_SEARCHINPUT)); - const verifyFilteredItems = (inputValue: string, - expectedDropdownItemsNumber: number, - expectedFilteredItemsNumber: number) => { + const verifyFilteredItems = (inputValue: string, expectedFilteredItemsNumber: number) => { UIInteractions.triggerInputEvent(searchInput, inputValue); fixture.detectChanges(); dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; dropdownItems = dropdownList.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); - expect(dropdownItems.length).toEqual(expectedDropdownItemsNumber); + expect(combo.filteredData.length).toEqual(expectedFilteredItemsNumber); + // A window over the collection, so it renders some of it, not all of it. + expect(dropdownItems.length).toBeGreaterThan(0); + expect(dropdownItems.length).toBeLessThanOrEqual(expectedFilteredItemsNumber); }; - verifyFilteredItems('M', 4, 15); - verifyFilteredItems('Mi', 3, 5); - verifyFilteredItems('M', 4, 15); + verifyFilteredItems('M', 15); + verifyFilteredItems('Mi', 5); + verifyFilteredItems('M', 15); combo.filteredData.forEach((item) => expect(combo.data).toContain(item)); })); it('should clear the search input and close the dropdown list on pressing ESC key', fakeAsync(() => { @@ -3729,12 +3836,32 @@ describe('igxCombo', () => { combo = fixture.componentInstance.combo; }); + it('should render the focused item after a keyboard event without a forced check', async () => { + combo.open(); + await fixture.whenStable(); + await combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + dropdownContent.nativeElement.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + + // Nothing is forced here: the component has to ask for the render itself. + await fixture.whenStable(); + + const focused = fixture.debugElement.nativeElement + .querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + expect(focused.length).toEqual(1); + expect(combo.dropdown.focusedItem).toBeTruthy(); + expect(focused[0]).toBe(combo.dropdown.focusedItem.element.nativeElement); + }); + it('should not reproduce NG0100 when virtualized combo items update on scroll - issue #17310', fakeAsync(() => { combo.open(); tick(); fixture.detectChanges(); - const scrollEl = combo.virtualScrollContainer.getScroll(); + const scrollEl = fixture.debugElement.query(By.css('igx-virtual-scroll')).nativeElement; expect(scrollEl).toBeTruthy(); scrollEl.scrollTop = 300; @@ -3763,7 +3890,7 @@ describe('igxCombo', () => { fixture.detectChanges(); expect(() => { - const scrollEl = combo.virtualScrollContainer.getScroll(); + const scrollEl = fixture.debugElement.query(By.css('igx-virtual-scroll')).nativeElement; scrollEl.scrollTop = 1000; scrollEl.dispatchEvent(new Event('scroll')); @@ -4051,6 +4178,77 @@ export class LocalService { } } +@Injectable() +export class DeferredRemoteDataService { + /** Every request made so far, in order, each waiting for the test to answer it. */ + public readonly requests: { state: IForOfState; subject: Subject }[] = []; + + private readonly source = Array.from({ length: 1000 }, + (_, id) => ({ id, product: `Product ${id}` })); + + public getData(state: IForOfState): Observable { + const subject = new Subject(); + this.requests.push({ state: { ...state }, subject }); + return subject.asObservable(); + } + + /** Answers one pending request with the page its own state asked for. */ + public complete(request: { state: IForOfState; subject: Subject }): void { + const size = request.state.chunkSize || 10; + const start = request.state.startIndex; + request.subject.next(this.source.slice(start, start + size)); + request.subject.complete(); + } +} + +@Component({ + template: ` + + + `, + providers: [DeferredRemoteDataService], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxComboComponent] +}) +export class IgxComboDeferredRemoteComponent implements AfterViewInit, OnDestroy { + public service = inject(DeferredRemoteDataService); + private cdr = inject(ChangeDetectorRef); + + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public instance: IgxComboComponent; + + public data: any[] = []; + + private pending: Subscription | null = null; + + public ngAfterViewInit() { + this.request({ startIndex: 0, chunkSize: 10 }); + } + + /** + * The documented pattern: the window the event carries is the one requested, and the + * request already in flight is dropped before the next one starts. + */ + public dataLoading(state: IForOfState) { + this.request(state); + } + + public ngOnDestroy() { + this.pending?.unsubscribe(); + this.cdr.detach(); + } + + private request(state: IForOfState) { + this.pending?.unsubscribe(); + this.pending = this.service.getData(state).subscribe(page => { + this.data = page; + this.instance.totalItemCount = 1000; + this.cdr.detectChanges(); + }); + } +} + @Component({ template: ` diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index 830a3b0e3df..84501bf848e 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -23,12 +23,12 @@ import { CancelableEventArgs, EditorProvider } from 'igniteui-angular/core'; -import { IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxVirtualItemDirective, IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; import { IgxRippleDirective } from 'igniteui-angular/directives'; import { IgxButtonDirective } from 'igniteui-angular/directives'; import { IgxComboItemComponent } from './combo-item.component'; import { IgxComboDropDownComponent } from './combo-dropdown.component'; -import { IgxComboFilteringPipe, IgxComboGroupingPipe } from './combo.pipes'; +import { IgxComboDataWindowPipe, IgxComboFilteringPipe, IgxComboGroupingPipe } from './combo.pipes'; import { IGX_COMBO_COMPONENT, IgxComboBaseDirective } from './combo.common'; import { IgxComboAddItemComponent } from './combo-add-item.component'; import { IgxComboAPIService } from './combo.api'; @@ -126,14 +126,16 @@ const diffInSets = (set1: Set, set2: Set): any[] => { IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, - IgxForOfDirective, + IgxVirtualScrollComponent, + IgxVirtualItemDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxReadOnlyInputDirective, IgxComboFilteringPipe, - IgxComboGroupingPipe + IgxComboGroupingPipe, + IgxComboDataWindowPipe ] }) export class IgxComboComponent extends IgxComboBaseDirective implements AfterViewInit, ControlValueAccessor, OnInit, diff --git a/projects/igniteui-angular/combo/src/combo/combo.pipes.ts b/projects/igniteui-angular/combo/src/combo/combo.pipes.ts index 9a9b2c62061..2528d3ebb83 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.pipes.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.pipes.ts @@ -1,8 +1,30 @@ import { Pipe, PipeTransform, inject } from '@angular/core'; +import { VirtualDataWindow } from 'igniteui-angular/virtual-scroll'; import { IComboFilteringOptions, IgxComboBase, IGX_COMBO_COMPONENT } from './combo.common'; import { SortingDirection } from 'igniteui-angular/core'; /** @hidden */ +/** + * @hidden @internal + * The items the drop-down has and where they sit in the collection they came from. Pure, so + * the window keeps its identity while its inputs do. + */ +@Pipe({ + name: 'comboDataWindow', + standalone: true +}) +export class IgxComboDataWindowPipe implements PipeTransform { + public transform( + collection: any[], totalItemCount: number, startIndex: number + ): VirtualDataWindow { + return totalItemCount > 0 + ? { items: collection, startIndex, totalCount: totalItemCount } + : { items: collection, startIndex: 0, totalCount: collection.length }; + } +} + + + @Pipe({ name: 'comboFiltering', standalone: true diff --git a/projects/igniteui-angular/combo/src/combo/themes/_base.scss b/projects/igniteui-angular/combo/src/combo/themes/_base.scss index daaf5b90c7a..9f70172c27b 100644 --- a/projects/igniteui-angular/combo/src/combo/themes/_base.scss +++ b/projects/igniteui-angular/combo/src/combo/themes/_base.scss @@ -58,14 +58,16 @@ $theme: digest-schema($light-combo); } @include e(content) { - .igx-vhelper--vertical { - position: relative; - } - position: relative; overflow: hidden; max-height: calc(var(--size) * var(--item-count)); + // The list is the scrolling viewport, so it takes the height the content is + // allowed rather than growing to its own extent. + igx-virtual-scroll { + max-height: inherit; + } + &:focus { outline: transparent; } diff --git a/projects/igniteui-angular/drop-down/README.md b/projects/igniteui-angular/drop-down/README.md index 3bbcc0db303..abdf7dbdb1b 100644 --- a/projects/igniteui-angular/drop-down/README.md +++ b/projects/igniteui-angular/drop-down/README.md @@ -71,7 +71,37 @@ The ***igx-drop-down-item-group*** component can be used inside of the ***igx-dr ***NOTE:*** The ***igx-drop-down-item-group*** tag can be used for grouping of ***igx-drop-down-item*** only an will forfeit any other content passed to it. ## Virtualized item list -The `igx-drop-down` supports the use of `IgxForOf` directive for displaying very large lists of data. To use a virtualized list of items in the drop-down, follow the steps below: +The `igx-drop-down` can display very large lists of data with either `IgxVirtualScrollComponent` or the `IgxForOf` directive. Both are supported; pick one for a given drop-down. + +### Using IgxVirtualScrollComponent +Project an `igx-virtual-scroll` and template its items with `igxVirtualItem`. The template context gives the item and its index in the whole collection, which are what `igx-drop-down-item` binds to: + +```typescript + import { IgxDropDownComponent, IgxDropDownItemComponent } from 'igniteui-angular/drop-down'; + import { IgxVirtualItemDirective, IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; +``` + +```html + + + + + {{ item.data }} + + + + +``` + +The scrolling host needs a real height — it is the element that scrolls, so no wrapping container is required. A drop-down is closed until the change detection pass that opens it, so the list has no size to measure in that pass; `initialViewportSize` gives that first render a size to work from and the measured height takes over afterwards. + +`index` is the item's index in the whole collection, so it stays correct as rows are recycled. + +See the [virtual scroll README](../virtual-scroll/README.md) for the rest of its API. + +### Using the IgxForOf directive +To use `*igxFor` instead, follow the steps below: ### Import IgxForOfModule ```typescript @@ -100,7 +130,9 @@ Configure the drop-down to use `*igxFor` instead of `ngFor`. Some additional con ``` Furthermore, when using `*igxFor` in the drop-down template, items must have `value` and `index` bound. The `value` property should be unique for each item. -### Styling the container +### Styling the container for the IgxForOf directive +This applies to the `*igxFor` variant above. An `igx-virtual-scroll` is itself the scrolling element and needs no wrapper. + In order for the drop-down list to properly display, the drop-down items must be wrapped in a container element (e.g. `
`). The container element must have the following styles: - `overflow: hidden;` @@ -133,7 +165,7 @@ The following outputs are available in the **igx-drop-down** component: | `closing` | true | Emitted before the dropdown is closed. | `IBaseCancelableBrowserEventArgs` | | `closed` | false | Emitted when a dropdown is being closed. | `IBaseEventArgs` | -***NOTE:*** The using `*igxFor` to virtualize `igx-drop-down-item`s, `selectionChanging` will emit `newSeleciton` and `oldSelection` as type `{ value: any, index: number }`. +***NOTE:*** When the `igx-drop-down-item`s are virtualized, with either `igx-virtual-scroll` or `*igxFor`, `selectionChanging` will emit `newSelection` and `oldSelection` as type `{ value: any, index: number }`. ## Methods The following methods are available in the **igx-drop-down** component: @@ -158,7 +190,7 @@ The following getters are available on the **igx-drop-down** component: | `element`| `ElementRef` | Get dropdown html element. | | `scrollContainer`| `ElementRef` | Get drop down's html element of its scroll container. | -***NOTE:*** The using `*igxFor` to virtualize `igx-drop-down-item`s, `selectedItem` will return type `{ value: any, index: number }`, where `value` is the item's bound `value` property and `index` is the item's index property in the data set. +***NOTE:*** When the `igx-drop-down-item`s are virtualized, with either `igx-virtual-scroll` or `*igxFor`, `selectedItem` will return type `{ value: any, index: number }`, where `value` is the item's bound `value` property and `index` is the item's index property in the data set. The following table summarizes some of the useful **igx-drop-down-item** component inputs, outputs and methods. diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-virtualization.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-virtualization.ts new file mode 100644 index 00000000000..ac0557e6f72 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-virtualization.ts @@ -0,0 +1,234 @@ +import { ElementRef } from '@angular/core'; +import { outputToObservable } from '@angular/core/rxjs-interop'; +import { Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; +import { IgxForOfToken } from 'igniteui-angular/directives'; +import { IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; +import { Navigate } from './drop-down.common'; + +/** + * @hidden @internal + * + * A drop-down virtualizes its items with either a projected `*igxFor` or a projected + * `igx-virtual-scroll`. What differs between the two lives behind this, so the drop-down keeps + * one path for navigation, scrolling and active-descendant tracking. + * + * Items are addressed by their index in the whole collection, never by their position in + * whatever subset is loaded: a paged collection holds a window somewhere in the middle, so + * there is no array to index directly. + */ +export interface IgxDropDownVirtualization { + /** How many items the collection has, including any a remote service has not sent. */ + readonly length: number; + + /** The collection index the loaded items begin at; 0 unless the collection is paged. */ + readonly startIndex: number; + + /** The element that scrolls, for listeners and for restoring the offset on reopen. */ + readonly scrollElement: HTMLElement; + + /** Scroll offset of the virtualized viewport. */ + scrollPosition: number; + + /** The item at a collection index, or `undefined` when it is not loaded. */ + itemAt(index: number): any; + + /** The collection index of the first loaded item that matches, or -1. */ + findIndex(predicate: (item: any) => boolean): number; + + /** Whether an index currently has an element in the DOM. */ + isIndexRendered(index: number): boolean; + + /** Brings `index` into view, then runs `onRendered` once an element exists for it. */ + scrollToIndex(index: number, direction: Navigate, onRendered: () => void): void; + + /** Puts `index` in the middle of the viewport, for revealing the selection on open. */ + alignToIndex(index: number): void; + + /** Runs `callback` whenever the rendered window changes. */ + onWindowChange(callback: () => void): void; + + /** Drops the subscriptions, for when the projected content is replaced. */ + disconnect(): void; +} + +/** The virtualization a drop-down was given, or `null` when its items are all rendered. */ +export function createDropDownVirtualization( + forOf: IgxForOfToken | undefined, + virtualScroll: IgxVirtualScrollComponent | undefined, + virtualScrollRef: ElementRef | undefined +): IgxDropDownVirtualization | null { + if (virtualScroll && virtualScrollRef) { + return new VirtualScrollVirtualization(virtualScroll, virtualScrollRef); + } + return forOf ? new ForOfVirtualization(forOf) : null; +} + +/** Items virtualized by a projected `igx-virtual-scroll`, which is itself the scrolling element. */ +class VirtualScrollVirtualization implements IgxDropDownVirtualization { + private readonly _disconnect = new Subject(); + + /** The window last rendered, for answering whether an index has an element. */ + private _rendered = { startIndex: 0, endIndex: -1 }; + + constructor( + private _scroll: IgxVirtualScrollComponent, + private _ref: ElementRef + ) { + outputToObservable(this._scroll.stateChange) + .pipe(takeUntil(this._disconnect)) + .subscribe(state => this._rendered = state); + } + + public get length(): number { + const window = this._scroll.dataWindow(); + return window ? window.totalCount : (this._scroll.data() ?? []).length; + } + + public get startIndex(): number { + return this._scroll.dataWindow()?.startIndex ?? 0; + } + + public get scrollElement(): HTMLElement { + return this._ref.nativeElement; + } + + public get scrollPosition(): number { + return this.scrollElement.scrollTop; + } + + public set scrollPosition(value: number) { + this.scrollElement.scrollTop = value ?? 0; + } + + public itemAt(index: number): any { + const window = this._scroll.dataWindow(); + return window + ? window.items[index - window.startIndex] + : (this._scroll.data() ?? [])[index]; + } + + public findIndex(predicate: (item: any) => boolean): number { + const window = this._scroll.dataWindow(); + const items = window ? window.items : (this._scroll.data() ?? []); + const found = items.findIndex(predicate); + return found < 0 ? -1 : found + (window?.startIndex ?? 0); + } + + public isIndexRendered(index: number): boolean { + return index >= this._rendered.startIndex && index <= this._rendered.endIndex; + } + + /** + * `'nearest'` leaves the offset alone when the item is already fully in view, so one call + * covers an item on screen and one far down the list. + */ + public scrollToIndex(index: number, _direction: Navigate, onRendered: () => void): void { + const wasRendered = this.isIndexRendered(index); + const scrolled = this._scroll.scrollToIndex(index, { block: 'nearest' }); + + if (wasRendered) { + onRendered(); + return; + } + void scrolled.then(onRendered); + } + + public alignToIndex(index: number): void { + void this._scroll.scrollToIndex(index, { block: 'center' }); + } + + public onWindowChange(callback: () => void): void { + outputToObservable(this._scroll.stateChange) + .pipe(takeUntil(this._disconnect)) + .subscribe(() => callback()); + } + + public disconnect(): void { + this._disconnect.next(); + this._disconnect.complete(); + } +} + +/** Items virtualized by a projected `*igxFor`, which keeps a scrollbar of its own. */ +class ForOfVirtualization implements IgxDropDownVirtualization { + private readonly _disconnect = new Subject(); + + /** `*igxFor` is bound to the whole collection, so it always starts at its beginning. */ + public readonly startIndex = 0; + + constructor(private _forOf: IgxForOfToken) { } + + public get length(): number { + return this._forOf.totalItemCount || this._items.length; + } + + public get scrollElement(): HTMLElement { + return this._forOf.getScroll()!; + } + + public get scrollPosition(): number { + return this._forOf.scrollPosition; + } + + public set scrollPosition(value: number) { + this._forOf.scrollPosition = value; + } + + public itemAt(index: number): any { + return this._items[index]; + } + + public findIndex(predicate: (item: any) => boolean): number { + return this._items.findIndex(predicate); + } + + public isIndexRendered(index: number): boolean { + const { startIndex, chunkSize } = this._forOf.state; + return index >= startIndex! && index < startIndex! + chunkSize!; + } + + public scrollToIndex(index: number, direction: Navigate, onRendered: () => void): void { + if (!this._needsScroll(index, direction)) { + onRendered(); + return; + } + + this._forOf.scrollTo(index); + this._forOf.chunkLoad.pipe(take(1)).subscribe(() => onRendered()); + } + + public alignToIndex(index: number): void { + const itemSize = this._forOf.igxForItemSize as number; + const itemsInView = (this._forOf.igxForContainerSize as number) / itemSize; + + this._forOf.getScroll()!.scrollTop = + this._forOf.getScrollForIndex(index) - (itemsInView / 2 - 1) * itemSize; + } + + public onWindowChange(callback: () => void): void { + this._forOf.chunkLoad.pipe(takeUntil(this._disconnect)).subscribe(() => callback()); + } + + public disconnect(): void { + this._disconnect.next(); + this._disconnect.complete(); + } + + /** `*igxFor` is bound to the whole collection, so its indices are already global. */ + private get _items(): any[] { + return this._forOf.igxForOf ?? []; + } + + /** Whether `index` is outside the loaded chunk, or inside it but off screen. */ + private _needsScroll(index: number, direction: Navigate): boolean { + const currentPosition = this._forOf.getScroll()!.scrollTop; + const itemPosition = this._forOf.getScrollForIndex(index, direction === Navigate.Down); + + const offScreen = direction === Navigate.Down + ? currentPosition < itemPosition + : currentPosition > itemPosition; + + return !this.isIndexRendered(index) || offScreen; + } +} diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts index a69f9d8c339..86c23f2ff02 100644 --- a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.spec.ts @@ -1,4 +1,4 @@ -import { Component, ViewChild, OnInit, ElementRef, ViewChildren, QueryList, ChangeDetectorRef, DOCUMENT, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { Component, ViewChild, OnInit, ElementRef, ViewChildren, QueryList, ChangeDetectorRef, DOCUMENT, ChangeDetectionStrategy, provideZonelessChangeDetection, signal } from '@angular/core'; import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -6,6 +6,8 @@ import { IgxToggleActionDirective, IgxToggleDirective } from '../../../directive import { IgxDropDownItemComponent } from './drop-down-item.component'; import { IgxDropDownComponent, IgxDropDownItemNavigationDirective } from './public_api'; import { ISelectionEventArgs } from './drop-down.common'; +import { IgxVirtualItemDirective, IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; +import { createDropDownVirtualization } from './drop-down-virtualization'; import { IgxTabContentComponent, IgxTabHeaderComponent, IgxTabItemComponent, IgxTabsComponent } from 'igniteui-angular/tabs'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; import { CancelableEventArgs, IBaseCancelableBrowserEventArgs, THEME_TOKEN } from 'igniteui-angular/core'; @@ -44,7 +46,10 @@ describe('IgxDropDown ', () => { } = jasmine.createSpyObj('IgxSelectionAPIService', ['get', 'set', 'add_items', 'select_items', 'delete']); const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); mockSelection.get.and.returnValue(new Set([])); - const mockForOf = jasmine.createSpyObj('IgxForOfDirective', ['totalItemCount']); + const virtualization = { + itemAt: (index: number) => data[index], + disconnect: () => { } + }; const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null } }); beforeEach(() => { @@ -61,7 +66,7 @@ describe('IgxDropDown ', () => { dropdown = TestBed.inject(IgxDropDownComponent); }); it('should notify when selection has changed', () => { - (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); @@ -74,7 +79,7 @@ describe('IgxDropDown ', () => { expect(dropdown.selectionChanging.emit).toHaveBeenCalledTimes(2); }); it('should fire selectionChanging with correct args', () => { - (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); @@ -97,7 +102,7 @@ describe('IgxDropDown ', () => { expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(newSelectionArgs); }); it('should notify when selection is cleared', () => { - (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); spyOn(dropdown.closed, 'emit').and.callThrough(); @@ -126,8 +131,7 @@ describe('IgxDropDown ', () => { expect(dropdown.selectionChanging.emit).toHaveBeenCalledWith(args); }); it('setSelectedItem should return selected item', () => { - (dropdown as any).virtDir = mockForOf; - (dropdown as any).virtDir.igxForOf = data; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); expect(dropdown.selectedItem).toBeNull(); @@ -136,21 +140,22 @@ describe('IgxDropDown ', () => { const selectedItem = dropdown.selectedItem; expect(selectedItem).toBeTruthy(); expect(selectedItem.index).toEqual(3); + expect(selectedItem.value).toBe(data[3]); }); it('setSelectedItem should return null when selection is cleared', () => { - (dropdown as any).virtDir = mockForOf; - (dropdown as any).virtDir.igxForOf = data; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); dropdown.setSelectedItem(3); expect(dropdown.selectedItem).toBeTruthy(); expect(dropdown.selectedItem.index).toEqual(3); + expect(dropdown.selectedItem.value).toBe(data[3]); dropdown.clearSelection(); expect(dropdown.selectedItem).toBeNull(); }); it('toggle should call open method when dropdown is collapsed', () => { - (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtualization = virtualization; spyOnProperty(dropdown, 'items', 'get').and.returnValue(data); spyOnProperty(dropdown, 'collapsed', 'get').and.returnValue(true); spyOn(dropdown, 'open'); @@ -159,7 +164,7 @@ describe('IgxDropDown ', () => { expect(dropdown.open).toHaveBeenCalledTimes(1); }); it('toggle should call close method when dropdown is opened', () => { - (dropdown as any).virtDir = mockForOf; + (dropdown as any).virtualization = virtualization; const mockToggle = jasmine.createSpyObj('IgxToggleDirective', ['open']); mockToggle.isClosing = false; (dropdown as any).toggleDirective = mockToggle; @@ -1029,6 +1034,163 @@ describe('IgxDropDown ', () => { expect(expectedScroll - acceptableDelta < scrollTop && expectedScroll + acceptableDelta > scrollTop).toBe(true); }); }); + describe('Projected virtual scroll lifecycle', () => { + let host: DynamicVirtualScrollDropDownComponent; + + const settle = async () => { + await fixture.whenStable(); + const scroll = host.scrolls.first; + if (scroll) { + await scroll.layoutComplete; + } + await fixture.whenStable(); + }; + + const focusedRow = () => + fixture.nativeElement.querySelector('.igx-drop-down__item--focused'); + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, DynamicVirtualScrollDropDownComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(DynamicVirtualScrollDropDownComponent); + host = fixture.componentInstance; + dropdown = host.dropdown; + await settle(); + }); + + it('should navigate a virtual scroll projected after initialization', async () => { + dropdown.open(); + await settle(); + + host.show.set(true); + await settle(); + + dropdown.navigateLast(); + await settle(); + + expect(dropdown.focusedItem?.value).toBe(99); + expect(focusedRow()?.textContent).toContain('99'); + }); + + it('should navigate again after the virtual scroll is removed and projected once more', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + host.show.set(false); + await settle(); + + host.show.set(true); + await settle(); + + dropdown.navigateLast(); + await settle(); + + expect(dropdown.focusedItem?.value).toBe(99); + expect(focusedRow()?.textContent).toContain('99'); + }); + + it('should navigate the replacement when the projected instance changes', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + const first = host.scrolls.first; + + // A different instance for the same slot, over a different collection. + host.useSecond.set(true); + await settle(); + + expect(host.scrolls.first).not.toBe(first); + + dropdown.navigateLast(); + await settle(); + + expect(dropdown.focusedItem?.value).toBe(199); + expect(focusedRow()?.textContent).toContain('199'); + }); + + it('should drive the element the replacement actually renders in', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + const originalElement = host.elements.first.nativeElement; + expect((dropdown as any).virtualization.scrollElement).toBe(originalElement); + + host.useSecond.set(true); + await settle(); + + const replacementElement = host.elements.first.nativeElement; + expect(replacementElement).not.toBe(originalElement); + expect(originalElement.isConnected).toBeFalse(); + expect(replacementElement.isConnected).toBeTrue(); + + // The two queries do not refresh together, so the adapter can end up holding the + // element of the instance it replaced. + expect((dropdown as any).virtualization.scrollElement).toBe(replacementElement); + }); + + it('should reset the viewport the replacement renders in', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + host.useSecond.set(true); + await settle(); + dropdown.navigateLast(); + await settle(); + + const element = host.elements.first.nativeElement; + expect(dropdown.focusedItem?.value).toBe(199); + expect(element.scrollTop).toBeGreaterThan(0); + expect(dropdown.selectedItem).toBeNull(); + + // Also reached through open(), so it has to act on the viewport on screen. + dropdown.updateScrollPosition(); + + expect(element.scrollTop).toBe(0); + }); + + it('should disconnect the adapter it replaces', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + const previous = (dropdown as any).virtualization; + const disconnect = spyOn(previous, 'disconnect').and.callThrough(); + + host.useSecond.set(true); + await settle(); + + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it('should stop window callbacks once an adapter is disconnected', async () => { + dropdown.open(); + host.show.set(true); + await settle(); + + const adapter = createDropDownVirtualization( + undefined, host.scrolls.first, host.elements.first); + const callback = jasmine.createSpy('window callback'); + adapter.onWindowChange(callback); + + const state = { startIndex: 0, endIndex: 0, viewportSize: 200, totalSize: 2800 } as any; + host.scrolls.first.stateChange.emit(state); + expect(callback).toHaveBeenCalledTimes(1); + + adapter.disconnect(); + host.scrolls.first.stateChange.emit(state); + + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + describe('Zoneless virtualization tests', () => { let scroll: IgxForOfDirective; beforeEach(async () => { @@ -1456,6 +1618,45 @@ describe('IgxDropDown ', () => { }); }); +@Component({ + template: ` + @if (show()) { + @if (useSecond()) { + + + {{item}} + + + } @else { + + + {{item}} + + + } + } + `, + imports: [IgxDropDownComponent, IgxDropDownItemComponent, IgxVirtualItemDirective, IgxVirtualScrollComponent], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DynamicVirtualScrollDropDownComponent { + @ViewChild(IgxDropDownComponent, { static: true }) + public dropdown: IgxDropDownComponent; + + @ViewChildren(IgxVirtualScrollComponent) + public scrolls: QueryList>; + + @ViewChildren(IgxVirtualScrollComponent, { read: ElementRef }) + public elements: QueryList>; + + public show = signal(false); + public useSecond = signal(false); + public items = Array.from({ length: 100 }, (_, i) => i); + public other = Array.from({ length: 200 }, (_, i) => i); +} + @Component({ template: ` diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts index 1502f26de69..d9ea7152895 100644 --- a/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down.component.ts @@ -8,7 +8,6 @@ import { Input, OnDestroy, ViewChild, - ContentChild, AfterViewInit, Output, EventEmitter, @@ -26,10 +25,12 @@ import { IGX_DROPDOWN_BASE, IDropDownBase } from './drop-down.common'; import { ISelectionEventArgs } from './drop-down.common'; import { IBaseCancelableBrowserEventArgs, IBaseEventArgs } from 'igniteui-angular/core'; import { IgxSelectionAPIService } from 'igniteui-angular/core'; -import { Subject } from 'rxjs'; +import { merge, Subject } from 'rxjs'; import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; import { IgxForOfToken } from 'igniteui-angular/directives'; -import { take, takeUntil } from 'rxjs/operators'; +import { IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; +import { createDropDownVirtualization, IgxDropDownVirtualization } from './drop-down-virtualization'; +import { takeUntil } from 'rxjs/operators'; import { OverlaySettings } from 'igniteui-angular/core'; import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; @@ -150,8 +151,17 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID @Input() public role = 'listbox'; - @ContentChild(IgxForOfToken) - protected virtDir!: IgxForOfToken; + @ContentChildren(IgxForOfToken, { descendants: true }) + private _forOfQuery!: QueryList>; + + @ContentChildren(IgxVirtualScrollComponent, { descendants: true }) + private _virtualScrollQuery!: QueryList>; + + @ContentChildren(IgxVirtualScrollComponent, { read: ElementRef, descendants: true }) + private _virtualScrollRefQuery!: QueryList>; + + /** Set from the projected content in `ngAfterViewInit`, and again if that content changes. */ + protected virtualization: IgxDropDownVirtualization | null = null; @ViewChild(IgxToggleDirective, { static: true }) protected toggleDirective!: IgxToggleDirective; @@ -163,7 +173,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public override get focusedItem(): IgxDropDownItemBaseDirective | null { - if (this.virtDir) { + if (this.virtualization) { return this._focusedItem && this._focusedItem.index !== -1 ? (this.children.find(e => e.index === this._focusedItem.index) || null) : null; @@ -179,7 +189,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID return; } this._focusedItem = value; - if (this.virtDir) { + if (this.virtualization) { this._focusedItem = { value: value.value, index: value.index @@ -189,7 +199,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID } public override get activeDescendant(): string | null { - if (this.virtDir) { + if (this.virtualization) { return this._activeDescendantId; } return super.activeDescendant; @@ -243,14 +253,19 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID } protected get collectionLength() { - if (this.virtDir) { - return this.virtDir.totalItemCount || this.virtDir.igxForOf!.length; + if (this.virtualization) { + return this.virtualization.length; } } protected destroy$ = new Subject(); protected _scrollPosition!: number; + /** The projected content the adapter was built for, to leave it alone while it stands. */ + private _connectedForOf?: IgxForOfToken; + private _connectedScroll?: IgxVirtualScrollComponent; + private _connectedElement?: ElementRef; + /** * Opens the dropdown * @@ -311,9 +326,9 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID return; } let newSelection: IgxDropDownItemBaseDirective; - if (this.virtDir) { + if (this.virtualization) { newSelection = { - value: this.virtDir.igxForOf![index], + value: this.virtualization.itemAt(index), index } as IgxDropDownItemBaseDirective; } else { @@ -329,27 +344,23 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @param newIndex number */ public override navigateItem(index: number) { - if (this.virtDir) { + if (this.virtualization) { if (index === -1 || index >= this.collectionLength!) { return; } const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up; - const subRequired = this.isIndexOutOfBounds(index, direction); this.focusedItem = { - value: this.virtDir.igxForOf![index], + value: this.virtualization.itemAt(index), index } as IgxDropDownItemBaseDirective; - if (subRequired) { - this.virtDir.scrollTo(index); - } - if (subRequired) { - this.virtDir.chunkLoad.pipe(take(1)).subscribe(() => { - this.skipHeader(direction); - }); - } else { - this._activeDescendantId = this.children.find(e => e.index === index)?.id ?? null; + + // Naming a row that has not rendered would point assistive technology at nothing. + this.refreshActiveDescendant(); + + this.virtualization.scrollToIndex(index, direction, () => { + this.refreshActiveDescendant(); this.skipHeader(direction); - } + }); } else { super.navigateItem(index); } @@ -363,18 +374,14 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public updateScrollPosition() { - if (!this.virtDir) { + if (!this.virtualization) { return; } if (!this.selectedItem) { - this.virtDir.scrollTo(0); + this.virtualization.scrollPosition = 0; return; } - let targetScroll = this.virtDir.getScrollForIndex(this.selectedItem.index); - // TODO: This logic _cannot_ be right, those are optional user-provided inputs that can be strings with units, refactor: - const itemsInView = this.virtDir.igxForContainerSize / this.virtDir.igxForItemSize; - targetScroll -= (itemsInView / 2 - 1) * this.virtDir.igxForItemSize; - this.virtDir.getScroll()!.scrollTop = targetScroll; + this.virtualization.alignToIndex(this.selectedItem.index); } /** @@ -388,8 +395,8 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID return; } - if (this.virtDir) { - this.virtDir.scrollPosition = this._scrollPosition; + if (this.virtualization) { + this.virtualization.scrollPosition = this._scrollPosition; } } @@ -397,7 +404,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public onToggleContentAppended(_event: ToggleViewEventArgs) { - if (!this.virtDir && this.selectedItem) { + if (!this.virtualization && this.selectedItem) { this.scrollToItem(this.selectedItem); } } @@ -420,8 +427,8 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID if (e.cancel) { return; } - if (this.virtDir) { - this._scrollPosition = this.virtDir.scrollPosition; + if (this.virtualization) { + this._scrollPosition = this.virtualization.scrollPosition; } } @@ -437,6 +444,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public ngOnDestroy() { + this.virtualization?.disconnect(); this.destroy$.next(true); this.destroy$.complete(); this.selection.delete(this.id); @@ -472,16 +480,57 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID } public ngAfterViewInit() { - if (this.virtDir) { - this.virtDir.igxForItemSize = 28; - this.virtDir.chunkLoad.pipe(takeUntil(this.destroy$)).subscribe(() => { - const item = this._focusedItem - ? this.children.find(e => e.index === this._focusedItem.index) - : null; - this._activeDescendantId = item?.id ?? null; - this.cdr.markForCheck(); - }); + this.connectVirtualization(); + + merge( + this._forOfQuery.changes, + this._virtualScrollQuery.changes, + this._virtualScrollRefQuery.changes + ) + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.connectVirtualization()); + } + + /** + * Rebuilds the adapter for the projected content, dropping the previous one first. + * The component and element queries can refresh separately, so the pair is taken as one. + */ + private connectVirtualization(): void { + const forOf = this._forOfQuery.first; + const scroll = this._virtualScrollQuery.first; + const element = this._virtualScrollRefQuery.first; + + // Re-applied whenever the projection reports, not only when the adapter is built: + // the directive recomputes its sizes as chunks load. + if (forOf) { + forOf.igxForItemSize = 28; + } + + if (scroll && !element) { + return; + } + + if (this._connectedForOf === forOf + && this._connectedScroll === scroll + && this._connectedElement === element) { + return; } + + this.virtualization?.disconnect(); + this._connectedForOf = forOf; + this._connectedScroll = scroll; + this._connectedElement = element; + this.virtualization = createDropDownVirtualization(forOf, scroll, element); + this.virtualization?.onWindowChange(() => this.refreshActiveDescendant()); + } + + /** Points `aria-activedescendant` at the element the focused index renders in, if any. */ + protected refreshActiveDescendant(): void { + const item = this._focusedItem + ? this.children?.find(e => e.index === this._focusedItem.index) + : null; + this._activeDescendantId = item?.id ?? null; + this.cdr.markForCheck(); } /** Keydown Handler */ @@ -496,7 +545,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public override navigateFirst() { - if (this.virtDir) { + if (this.virtualization) { this.navigateItem(0); } else { super.navigateFirst(); @@ -507,8 +556,8 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public override navigateLast() { - if (this.virtDir) { - this.navigateItem(this.virtDir.totalItemCount ? this.virtDir.totalItemCount - 1 : this.virtDir.igxForOf!.length - 1); + if (this.virtualization) { + this.navigateItem(this.virtualization.length - 1); } else { super.navigateLast(); } @@ -518,7 +567,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public override navigateNext() { - if (this.virtDir) { + if (this.virtualization) { this.navigateItem(this._focusedItem ? this._focusedItem.index + 1 : 0); } else { super.navigateNext(); @@ -529,7 +578,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public override navigatePrev() { - if (this.virtDir) { + if (this.virtualization) { this.navigateItem(this._focusedItem ? this._focusedItem.index - 1 : 0); } else { super.navigatePrev(); @@ -556,7 +605,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID if (newSelection instanceof IgxDropDownItemBaseDirective && newSelection.isHeader) { return; } - if (this.virtDir) { + if (this.virtualization) { newSelection = { value: newSelection!.value, index: newSelection!.index @@ -571,7 +620,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID if (!args.cancel) { if (this.isSelectionValid(args.newSelection)) { this.selection.set(this.id, new Set([args.newSelection])); - if (!this.virtDir) { + if (!this.virtualization) { if (oldSelection) { oldSelection.selected = false; } @@ -613,7 +662,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID */ protected isSelectionValid(selection: any): boolean { return selection === null - || (this.virtDir && selection.hasOwnProperty('value') && selection.hasOwnProperty('index')) + || (!!this.virtualization && selection.hasOwnProperty('value') && selection.hasOwnProperty('index')) || (selection instanceof IgxDropDownItemComponent && !selection.isHeader); } @@ -650,14 +699,6 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID } } - private isIndexOutOfBounds(index: number, direction: Navigate) { - const virtState = this.virtDir.state; - const currentPosition = this.virtDir.getScroll()!.scrollTop; - const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down); - const indexOutOfChunk = index < virtState.startIndex! || index > virtState.chunkSize! + virtState.startIndex!; - const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition; - const subRequired = indexOutOfChunk || scrollNeeded; - return subRequired; - } + } diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html index b7e1f2d5544..a1d43ff632c 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.html @@ -36,34 +36,33 @@ (focus)="onFocus()" (focusout)="onFocusOut()" > -
- - + + - {{ item.label }} - - -
+ + {{ item.label }} + + + +
diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts index f384489a543..eb8461bdadc 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/excel-style-search.component.ts @@ -1,4 +1,4 @@ -import { AfterViewInit, Component, ViewChild, ChangeDetectorRef, TemplateRef, Directive, OnDestroy, HostBinding, Input, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, Component, ViewChild, ChangeDetectorRef, ElementRef, TemplateRef, Directive, OnDestroy, HostBinding, Input, inject, ChangeDetectionStrategy } from '@angular/core'; import { Subject } from 'rxjs'; import { IChangeCheckboxEventArgs, IgxCheckboxComponent } from 'igniteui-angular/checkbox'; import { takeUntil } from 'rxjs/operators'; @@ -9,11 +9,11 @@ import { FormsModule } from '@angular/forms'; import { IgxInputDirective, IgxInputGroupComponent, IgxPrefixDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxDataLoadingTemplateDirective, IgxEmptyListTemplateDirective, IgxListComponent, IgxListItemComponent } from 'igniteui-angular/list'; -import { IgxButtonDirective, IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxButtonDirective } from 'igniteui-angular/directives'; +import { IgxVirtualItemDirective, IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; import { IgxTreeComponent, IgxTreeNodeComponent, ITreeNodeSelectionEvent } from 'igniteui-angular/tree'; import { IgxCircularProgressBarComponent } from 'igniteui-angular/progressbar'; import { cloneHierarchicalArray, columnFieldPath, FilteringExpressionsTree, FilteringLogic, GridColumnDataType, IgxBooleanFilteringOperand, IgxDateFilteringOperand, IgxDateTimeFilteringOperand, IgxNumberFilteringOperand, IgxStringFilteringOperand, IgxTimeFilteringOperand, PlatformUtil, resolveNestedPath, ɵSize } from 'igniteui-angular/core'; -import { Navigate } from 'igniteui-angular/drop-down'; import { GridPagingMode } from '../../common/enums'; @Directive({ @@ -30,7 +30,11 @@ export class IgxExcelStyleLoadingValuesTemplateDirective { } let NEXT_ID = 0; + +/** Rows the search list is laid out to show at once. */ +const ITEMS_IN_VIEW = 10; const TREE_GRID_GROUPING_HIDDEN_FIELD = '_Igx_Hidden_Data_'; + /** * A component used for presenting Excel style search UI. */ @@ -38,7 +42,7 @@ const TREE_GRID_GROUPING_HIDDEN_FIELD = '_Igx_Hidden_Data_'; selector: 'igx-excel-style-search', templateUrl: './excel-style-search.component.html', changeDetection: ChangeDetectionStrategy.Eager, - imports: [IgxInputGroupComponent, IgxIconComponent, IgxPrefixDirective, FormsModule, IgxInputDirective, IgxSuffixDirective, IgxListComponent, IgxForOfDirective, IgxListItemComponent, IgxCheckboxComponent, IgxDataLoadingTemplateDirective, NgTemplateOutlet, IgxEmptyListTemplateDirective, IgxTreeComponent, IgxTreeNodeComponent, IgxCircularProgressBarComponent, IgxButtonDirective] + imports: [IgxInputGroupComponent, IgxIconComponent, IgxPrefixDirective, FormsModule, IgxInputDirective, IgxSuffixDirective, IgxListComponent, IgxVirtualScrollComponent, IgxVirtualItemDirective, IgxListItemComponent, IgxCheckboxComponent, IgxDataLoadingTemplateDirective, NgTemplateOutlet, IgxEmptyListTemplateDirective, IgxTreeComponent, IgxTreeNodeComponent, IgxCircularProgressBarComponent, IgxButtonDirective] }) export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { public cdr = inject(ChangeDetectorRef); @@ -89,8 +93,15 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { /** * @hidden @internal */ - @ViewChild(IgxForOfDirective) - protected virtDir!: IgxForOfDirective; + @ViewChild('virtualScroll') + protected virtualScroll?: IgxVirtualScrollComponent; + + /** + * @hidden @internal + * The list host, which is the element that scrolls. + */ + @ViewChild('virtualScroll', { read: ElementRef }) + protected virtualScrollRef?: ElementRef; /** * @hidden @internal @@ -187,10 +198,8 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { private _id = `igx-excel-style-search-${NEXT_ID++}`; private _isLoading = true; - private _containerSize = 0; private _addToCurrentFilterItem!: FilterListItem; private _selectAllItem!: FilterListItem; - private _measuredItemSize?: number; private _hierarchicalSelectedItems!: FilterListItem[]; private _focusedItem: ActiveElement = null!; private destroy$ = new Subject(); @@ -200,6 +209,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { esf.loadingStart.pipe(takeUntil(this.destroy$)).subscribe(() => { this.displayedListData = []; + this.reconcileEmptyList(); this.isLoading = true; }); esf.loadingEnd.pipe(takeUntil(this.destroy$)).subscribe(() => { @@ -213,11 +223,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { }); }); esf.columnChange.pipe(takeUntil(this.destroy$)).subscribe(() => { - this.virtDir?.resetScrollPosition(); - - if (this.virtDir) { - this.virtDir.state.startIndex = 0; - } + void this.virtualScroll?.scrollToIndex(0); }); esf.listDataLoaded.pipe(takeUntil(this.destroy$)).subscribe(() => { @@ -260,17 +266,9 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { * @hidden @internal */ public refreshSize = () => { - if (this.virtDir) { - this.updateContainerSize(); - const firstItem = this.list?.children.first; - const itemSize = firstItem?.element.getBoundingClientRect().height; - if (itemSize) { - // Excel filter rows are uniform; use the outer size to keep the scrollbar range stable. - this._measuredItemSize = itemSize; - } - this.virtDir.igxForContainerSize = this.containerSize; - this.virtDir.igxForItemSize = this.itemSize; - this.virtDir.recalcUpdateSizes(); + // The virtual scroll measures its own viewport and items; this only flushes the + // bindings that the surrounding menu changed (size, loading state, list data). + if (this.virtualScroll && !(this.cdr as any).destroyed) { this.cdr.detectChanges(); } } @@ -355,41 +353,34 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { /** * @hidden @internal + * The height the list is given. The menu it sits in is closed until the pass that opens + * it, so the list has no size to measure in that pass and needs one to start from. */ - public get itemSize() { - let itemSize = '40px'; - if (this._measuredItemSize) { - return `${this._measuredItemSize}px`; - } - const esf = this.esf as any; - switch (esf.size) { - case ɵSize.Medium: itemSize = '32px'; break; - case ɵSize.Small: itemSize = '28px'; break; - default: break; - } - return itemSize; + public get viewportSize(): number { + return this.itemSize * ITEMS_IN_VIEW; } /** * @hidden @internal + * The estimated height, in pixels, of a single list item for the current size. The + * virtual scroll replaces it with the real size once the items are measured in the DOM. */ - public get containerSize() { - return this._containerSize; + public get itemSize(): number { + const esf = this.esf as any; + switch (esf.size) { + case ɵSize.Medium: return 32; + case ɵSize.Small: return 28; + default: return 40; + } } /** * @hidden @internal - * Measures the rendered list height and caches it. Reading `offsetHeight` directly in - * the template binding throws ExpressionChangedAfterItHasBeenChecked when the list height - * settles during the same change-detection pass, so the measurement is taken here (from - * `refreshSize`, outside CD) and the getter returns the cached value. + * Rows are recycled as the rendered window moves, so a scroll with the wheel or the + * scrollbar can take the focused row's element away while the listbox still names its id. */ - private updateContainerSize() { - // GE Nov 1st, 2021 #10355 Keep a numeric value so the chunk size is calculated properly. - // A 0 (instead of undefined) makes _calculateChunkSize() off the ForOfDirective behave. - this._containerSize = this.esf.listData.length - ? (this.list?.element.nativeElement.clientHeight ?? 0) - : 0; + protected onVirtualStateChange(): void { + this.refreshActiveDescendant(); } @HostBinding('attr.id') @@ -405,10 +396,6 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { return `${this.id}-item-${index}`; } - protected setActiveDescendant(): void { - this.activeDescendant = this.focusedItem?.id || ''; - } - protected get focusedItem(): ActiveElement { return this._focusedItem; } @@ -457,6 +444,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { if (!this.esf.listData || !this.esf.listData.length) { this.displayedListData = []; + this.reconcileEmptyList(); return; } @@ -534,6 +522,8 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { } } + this.reconcileEmptyList(); + if (this.displayedListData.length > 2) { this.matchesCount = this.displayedListData.length - 2; } else { @@ -717,20 +707,20 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { } protected onFocus() { - const firstIndexInView = this.virtDir.state.startIndex!; - if (this.virtDir.igxForOf!.length > 0) { + const firstIndexInView = this.firstVisibleIndex(); + if (firstIndexInView < this.displayedListData.length) { this.focusedItem = { id: this.getItemId(firstIndexInView), index: firstIndexInView, - checked: this.virtDir.igxForOf![firstIndexInView].isSelected + checked: this.displayedListData[firstIndexInView].isSelected }; } - this.setActiveDescendant(); + this.refreshActiveDescendant(); } protected onFocusOut() { this.focusedItem = null!; - this.setActiveDescendant(); + this.refreshActiveDescendant(); } /** @@ -853,34 +843,30 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { } private onArrowUpKeyDown() { - if (this.focusedItem && this.focusedItem.index === 0 && this.virtDir.state.startIndex === 0) { + if (this.focusedItem && this.focusedItem.index === 0) { // on ArrowUp the focus stays on the same element if it is the first focused return; } else { this.navigateItem(this.focusedItem ? this.focusedItem.index - 1 : 0); } - this.setActiveDescendant(); } private onArrowDownKeyDown() { - const lastIndex = this.virtDir.igxForOf!.length - 1; + const lastIndex = this.displayedListData.length - 1; if (this.focusedItem && this.focusedItem.index === lastIndex) { // on ArrowDown the focus stays on the same element if it is the last focused return; } else { this.navigateItem(this.focusedItem ? this.focusedItem.index + 1 : 0); } - this.setActiveDescendant(); } private onHomeKeyDown() { this.navigateItem(0); - this.setActiveDescendant(); } private onEndKeyDown() { - this.navigateItem(this.virtDir.igxForOf!.length - 1); - this.setActiveDescendant(); + this.navigateItem(this.displayedListData.length - 1); } private onActionKeyDown() { @@ -895,29 +881,78 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { } private navigateItem(index: number) { - if (index === -1 || index >= this.virtDir.igxForOf!.length) { + if (index === -1 || index >= this.displayedListData.length) { return; } - const direction = index > (this.focusedItem ? this.focusedItem.index : -1) ? Navigate.Down : Navigate.Up; - const scrollRequired = this.isIndexOutOfBounds(index, direction); + this.focusedItem = { id: this.getItemId(index), - index: index, - checked: this.virtDir.igxForOf![index].isSelected + index, + checked: this.displayedListData[index].isSelected }; - if (scrollRequired) { - this.virtDir.scrollTo(index); + + // Names the row straight away when it is already rendered, and nothing while the + // scroll below is still bringing it into the window. + this.refreshActiveDescendant(); + + // 'nearest' leaves the scroll position untouched when the item is already in view. + void this.virtualScroll?.scrollToIndex(index, { block: 'nearest' }) + .then(() => this.refreshActiveDescendant()); + } + + /** + * The first row the viewport shows. The rendered window reaches above it by the over-scan + * buffer, so its start index would focus a row that is off screen. Only the rendered + * wrappers are inspected, and only when focus enters the list. + */ + private firstVisibleIndex(): number { + const host = this.virtualScrollRef?.nativeElement; + if (!host) { + return 0; } + + const wrappers = Array.from(host.querySelectorAll('[data-vs-index]')); + if (!wrappers.length) { + return 0; + } + + const viewportTop = host.getBoundingClientRect().top; + for (const wrapper of wrappers) { + if (wrapper.getBoundingClientRect().bottom > viewportTop + 1) { + return Number(wrapper.dataset['vsIndex']); + } + } + + // Every rendered row sits above the viewport; the window starts at the first of them. + return Number(wrappers[0].dataset['vsIndex']); } - private isIndexOutOfBounds(index: number, direction: Navigate) { - const virtState = this.virtDir.state; - const currentPosition = this.virtDir.getScroll().scrollTop; - const itemPosition = this.virtDir.getScrollForIndex(index, direction === Navigate.Down); - const indexOutOfChunk = index < virtState.startIndex! || index > virtState.chunkSize! + virtState.startIndex!; - const scrollNeeded = direction === Navigate.Down ? currentPosition < itemPosition : currentPosition > itemPosition; - const subRequired = indexOutOfChunk || scrollNeeded; - return subRequired; + /** + * Clears the focused option when no displayed item remains, so the listbox stops + * naming a row that the empty render took away. + */ + private reconcileEmptyList(): void { + if (this.displayedListData.length) { + return; + } + + this.focusedItem = null!; + this.refreshActiveDescendant(); + } + + /** Names the focused row's element while it is rendered, and nothing while it is not. */ + private refreshActiveDescendant(): void { + const index = this._focusedItem?.index; + const id = index !== undefined ? this.getItemId(index) : ''; + // The rendered rows are the authority on whether that row exists. A cached range + // has to be told about every render, and a window the list renders again unchanged + // is not reported a second time. + const next = id && this.list?.children?.some(item => item.element.id === id) ? id : ''; + + if (this.activeDescendant !== next) { + this.activeDescendant = next; + this.cdr.markForCheck(); + } } private isTreeGridWithGroupBy(): boolean { diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/_base.scss b/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/_base.scss index bb20c5cd021..03b60050ec5 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/_base.scss +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/_base.scss @@ -190,6 +190,13 @@ $checkbox-indent: ( border: 0; border-top: rem(1px) dashed var-get($theme, 'border-color'); border-bottom: rem(1px) dashed var-get($theme, 'border-color'); + + // The virtual scroll is the scrolling viewport of the list, so it takes + // the height the list has left instead of being sized by its content. + igx-virtual-scroll { + flex: 1 1 auto; + min-height: 0; + } } } diff --git a/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/shared/_indigo.scss b/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/shared/_indigo.scss index dd58370752e..9a3595bc0fa 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/shared/_indigo.scss +++ b/projects/igniteui-angular/grids/core/src/filtering/excel-style/themes/shared/_indigo.scss @@ -119,13 +119,13 @@ $_theme: digest-schema($indigo-excel-filtering); border-block: rem(1px) dashed var(--_border-color, var(--ig-gray-100)); margin-inline: calc(#{sizable(rem(-16px))} * -1); - igx-display-container { + igx-virtual-scroll { padding-inline: pad(rem(8px)); } - // Keep the visual spacing inside the item size measured by the virtualizer. - .igx-list__item-base { - padding-block-end: rem(4px); + // Spacing sits inside the wrapper the virtualizer measures. + .igx-vs__item { + padding-block: calc(#{pad(rem(8px))} / 2); } } } diff --git a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts index b6eac714201..380f92a8545 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-filtering-ui.spec.ts @@ -1,4 +1,4 @@ -import { DebugElement } from '@angular/core'; +import { DebugElement, provideZonelessChangeDetection } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, ComponentFixture, waitForAsync } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -4100,27 +4100,36 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { const searchComponent = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; const listElement = searchComponent.list.element.nativeElement; listElement.style.border = '1px solid transparent'; + await searchComponent.virtualScroll.layoutComplete; + fix.detectChanges(); + const scroller = GridFunctions.getExcelStyleSearchComponentScrollbar(fix) as HTMLElement; expect(listElement.offsetHeight).toBeGreaterThan(listElement.clientHeight); - expect(searchComponent.containerSize).toBe(listElement.clientHeight); + // The virtual scroll takes the height the list has left for it, borders excluded. + expect(scroller.clientHeight).toBe(listElement.clientHeight); }); - it('Should initialize virtual item sizes from the rendered list item', async () => { + it('Should size the scrollable extent from the rendered row height', async () => { GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); fix.detectChanges(); await wait(100); const searchComponent = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; - const virtDir = searchComponent.virtDir; - const firstItem = searchComponent.list.children.first.element; - spyOn(firstItem, 'getBoundingClientRect').and.returnValue(DOMRect.fromRect({ height: 37 })); - - searchComponent.refreshSize(); + await searchComponent.virtualScroll.layoutComplete; fix.detectChanges(); - expect(searchComponent.itemSize).toBe('37px'); - expect(virtDir.igxForItemSize).toBe('37px'); - expect(virtDir.individualSizeCache.at(-1)).toBe(37); + const rows = GridFunctions.getExcelStyleSearchComponentListItems(fix); + const rowHeight = rows[0].getBoundingClientRect().height; + const track = GridFunctions.getExcelStyleSearchComponent(fix) + .querySelector('.igx-vs__track') as HTMLElement; + + // This column has few enough values that the list renders all of them, so every + // row here has been measured and the extent is their real height rather than the + // estimate the list started from. A collection large enough to virtualize keeps + // the estimate for the rows it has not rendered. + expect(rowHeight).toBeGreaterThan(0); + expect(Number.parseFloat(track.style.height)) + .toBeCloseTo(searchComponent.displayedListData.length * rowHeight, 0); }); it('Should allow to input commas in excel search component input field when column dataType is number.', async () => { @@ -4146,7 +4155,7 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix, searchComponent); expect(inputNativeElement.value).toBe('', 'incorrect rendered list items count'); - expect(listItems.length).toBe(8, 'incorrect rendered list items count'); + expect(listItems.length).toBe(9, 'incorrect rendered list items count'); }); it('Should match numeric column values when searching without locale-specific formatting characters.', async () => { @@ -4445,6 +4454,142 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { expect(listItems[2].innerText).toBe('False'); })); + it('should render the search list in the pass that opens the menu', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + + // No settling: the rows have to be there when the menu appears, or the list is + // briefly on screen and empty. + const listItems = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(listItems.length).toBeGreaterThan(0); + })); + + it('should go through an empty result and back without an expression error', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const input = GridFunctions.getExcelStyleSearchComponentInput(fix, searchComponent); + + // The list telling its wrapper it is empty, and then that it is not, has to + // settle within one pass; NG0100 would fail this test on its own. + UIInteractions.clickAndSendInputElementValue(input, 'nothing matches this', fix); + tick(100); + fix.detectChanges(); + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBe(0); + + UIInteractions.clickAndSendInputElementValue(input, '', fix); + tick(100); + fix.detectChanges(); + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBeGreaterThan(0); + })); + + it('should stop naming a row once the list has none left', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + + const searchComponent = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + await searchComponent.virtualScroll.layoutComplete; + fix.detectChanges(); + + const searchElement = GridFunctions.getExcelStyleSearchComponent(fix); + const list = searchElement.querySelector('igx-list') as HTMLElement; + list.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(list); + + const named = list.getAttribute('aria-activedescendant'); + expect(named).toBeTruthy(); + expect(searchElement.querySelector(`#${named}`)).toBeTruthy(); + + // Filtering to nothing takes every row away while the list still has focus. + const input = GridFunctions.getExcelStyleSearchComponentInput(fix, searchElement); + UIInteractions.clickAndSendInputElementValue(input, 'nothing matches this', fix); + fix.detectChanges(); + await searchComponent.virtualScroll.layoutComplete; + fix.detectChanges(); + + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBe(0); + + // The rows went away underneath a list that still holds focus. + expect(document.activeElement).toBe(list); + + const left = list.getAttribute('aria-activedescendant'); + expect(left).toBeFalsy(); + expect(left ? searchElement.querySelector(`#${left}`) : null).toBeNull(); + }); + + it('should name the keyboard focused row after an empty search is cleared', async () => { + GridFunctions.clickExcelFilterIconFromCodeAsync(fix, grid, 'ProductName'); + fix.detectChanges(); + + const search = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + await search.virtualScroll.layoutComplete; + fix.detectChanges(); + + // From here the test drives the component the way an application does: real events + // detect their own changes, and settling waits for the list to finish laying out. + fix.autoDetectChanges(); + const settle = async () => { + await fix.whenStable(); + await search.virtualScroll.layoutComplete; + await fix.whenStable(); + }; + + const searchElement = GridFunctions.getExcelStyleSearchComponent(fix); + const list = search.list.element.nativeElement as HTMLElement; + const input = GridFunctions.getExcelStyleSearchComponentInput(fix, searchElement); + + // Search for something no row matches, then take the search back out. + const searchAndClear = async () => { + input.value = 'nothing matches this'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settle(); + + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBe(0); + + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settle(); + + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBeGreaterThan(0); + }; + + // An empty list is not the same height as a full one, so the first round leaves the + // viewport at a size it did not have when the menu opened. The second round is the + // one that brings the rows back to a window the list has already reported, and so + // has no reason to report again. + await searchAndClear(); + await searchAndClear(); + + list.focus(); + await settle(); + + expect(document.activeElement).toBe(list); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await settle(); + + // The row the keyboard moved to is in the DOM, so the listbox has to name it, + // and the row it names has to be the one the focus is drawn on. + const named = list.getAttribute('aria-activedescendant'); + expect(named).toBeTruthy(); + expect(searchElement.querySelector(`#${named}`)).toBeTruthy(); + expect(list.querySelector('.igx-list__item-base--active')?.id).toBe(named); + }); + + it('should keep the rendered rows when the size changes', fakeAsync(() => { + GridFunctions.clickExcelFilterIconFromCode(fix, grid, 'ProductName'); + const before = GridFunctions.getExcelStyleSearchComponentListItems(fix); + const beforeHeight = before[0].getBoundingClientRect().height; + + setElementSize(grid.nativeElement, ɵSize.Small); + tick(100); + fix.detectChanges(); + + const after = GridFunctions.getExcelStyleSearchComponentListItems(fix); + expect(after.length).toBeGreaterThan(0); + expect(after[0].getBoundingClientRect().height).toBeLessThan(beforeHeight); + })); + it('should scroll items in search list correctly', (async () => { // Add additional rows as prerequisite for the test for (let index = 0; index < 30; index++) { @@ -4479,15 +4624,117 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { // Verify scrollbar's scrollTop. expect(scrollbar.scrollTop >= 740 && scrollbar.scrollTop <= 800).toBe(true, 'search scrollbar has incorrect scrollTop: ' + scrollbar.scrollTop); - // Verify display container height. - const displayContainer = searchComponent.querySelector('igx-display-container'); + // Verify the rendered window covers the viewport. + const displayContainer = searchComponent.querySelector('.igx-vs__content'); const displayContainerRect = displayContainer.getBoundingClientRect(); const listHeight = searchComponent.querySelector('igx-list').getBoundingClientRect().height; const itemHeight = displayContainer.querySelector('igx-list-item').getBoundingClientRect().height; - expect(displayContainerRect.height > listHeight + itemHeight && displayContainerRect.height < listHeight + (itemHeight * 2)).toBe(true, 'incorrect search display container height'); - // Verify rendered list items count. + // Verify rendered list items count: the visible rows plus the over-scan buffer + // on each side, which is 2 items by default. const listItems = displayContainer.querySelectorAll('igx-list-item'); - expect(listItems.length).toBe(Math.ceil(listHeight / itemHeight) + 1, 'incorrect rendered list items count'); + const visibleItems = Math.ceil(listHeight / itemHeight); + expect(listItems.length).toBeGreaterThanOrEqual(visibleItems, 'too few rendered list items'); + expect(listItems.length).toBeLessThanOrEqual(visibleItems + 5, 'too many rendered list items'); + expect(displayContainerRect.height).toBeGreaterThanOrEqual(listHeight); + expect(displayContainerRect.height).toBeLessThanOrEqual(listItems.length * itemHeight); + })); + + it('should focus the first row the viewport shows, not the over-scanned one', (async () => { + for (let index = 0; index < 30; index++) { + grid.addRow({ + Downloads: index, ID: index + 100, ProductName: 'New Product ' + index, + ReleaseDate: new Date(), Released: false, AnotherField: 'z' + }); + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + fix.detectChanges(); + await fix.whenStable(); + + const search = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + await search.virtualScroll.layoutComplete; + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const scroller = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + + // Land half a row down so the first row on screen is cut by the viewport edge + // rather than sitting flush against it. + const rowHeight = GridFunctions.getExcelStyleSearchComponentListItems(fix)[0] + .getBoundingClientRect().height; + scroller.scrollTop = rowHeight * 10 + rowHeight / 2; + scroller.dispatchEvent(new Event('scroll')); + fix.detectChanges(); + await search.virtualScroll.layoutComplete; + fix.detectChanges(); + + const list = searchComponent.querySelector('igx-list') as HTMLElement; + list.focus(); + fix.detectChanges(); + + expect(document.activeElement).toBe(list); + + // The rendered window reaches above the viewport by the over-scan buffer, so the + // focused row has to be the first one actually on screen. + const named = list.getAttribute('aria-activedescendant'); + const focused = searchComponent.querySelector(`#${named}`) as HTMLElement; + expect(focused).toBeTruthy(); + + const viewportTop = scroller.getBoundingClientRect().top; + const focusedBox = focused.getBoundingClientRect(); + + // It reaches into the viewport, and it is cut by the top edge rather than + // starting below it - a partially visible row still counts as shown. + expect(focusedBox.bottom).toBeGreaterThan(viewportTop); + expect(focusedBox.top).toBeLessThan(viewportTop); + + // Nothing rendered above it reaches the viewport, so it is the first that does + // and not merely one of the rows on screen. + const rows = GridFunctions.getExcelStyleSearchComponentListItems(fix); + const above = rows.slice(0, rows.indexOf(focused)); + expect(above.length).toBeGreaterThan(0); + for (const row of above) { + expect(row.getBoundingClientRect().bottom).toBeLessThanOrEqual(viewportTop + 1); + } + })); + + it('should never name a row that is not rendered', (async () => { + for (let index = 0; index < 30; index++) { + grid.addRow({ + Downloads: index, ID: index + 100, ProductName: 'New Product ' + index, + ReleaseDate: new Date(), Released: false, AnotherField: 'z' + }); + } + fix.detectChanges(); + + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + fix.detectChanges(); + await fix.whenStable(); + + const search = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + await search.virtualScroll.layoutComplete; + fix.detectChanges(); + + const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); + const list = searchComponent.querySelector('igx-list') as HTMLElement; + list.dispatchEvent(new Event('focus')); + fix.detectChanges(); + + const focusedFirst = list.getAttribute('aria-activedescendant'); + expect(focusedFirst).toBeTruthy(); + + // Scrolling recycles the wrappers, so the element the listbox names is taken away + // underneath it. + const scroller = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); + scroller.scrollTop = 3000; + scroller.dispatchEvent(new Event('scroll')); + fix.detectChanges(); + await search.virtualScroll.layoutComplete; + fix.detectChanges(); + + expect(searchComponent.querySelector(`#${focusedFirst}`)).toBeNull(); + expect(list.getAttribute('aria-activedescendant')).toBeFalsy(); })); it('should correctly display all items in search list after filtering it', (async () => { @@ -4512,7 +4759,7 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { // Scroll the search list to the middle. const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix); - const displayContainer = searchComponent.querySelector('igx-display-container') as HTMLElement; + const displayContainer = searchComponent.querySelector('.igx-vs__content') as HTMLElement; const scrollbar = GridFunctions.getExcelStyleSearchComponentScrollbar(fix); scrollbar.scrollTop = displayContainer.getBoundingClientRect().height / 2; await wait(200); @@ -4761,8 +5008,8 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { fix.detectChanges(); verifyExcelStyleFilterAvailableOptions(fix, - ['Select All', '(Blanks)', '0', '20', '100', '127', '254', '702'], - [true, true, true, true, true, true, true, true]); + ['Select All', '(Blanks)', '0', '20', '100', '127', '254', '702', '1,000'], + [true, true, true, true, true, true, true, true, true]); GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); tick(100); @@ -7340,6 +7587,87 @@ describe('IgxGrid - Filtering actions - Excel style filtering #grid', () => { }); }); +describe('IgxGrid - Excel style filtering zoneless #grid', () => { + let fix: ComponentFixture; + let grid: IgxGridComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + IgxGridFilteringComponent + ], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + })); + + beforeEach(async () => { + fix = TestBed.createComponent(IgxGridFilteringComponent); + fix.detectChanges(); + grid = fix.componentInstance.grid; + grid.filterMode = FilterMode.excelStyleFilter; + fix.detectChanges(); + await fix.whenStable(); + }); + + // The zone-based copy of this lives in the Excel style filtering suite above. Here no + // zone reports the work, so every render the assertions read has to have been asked for + // by the events themselves. + it('should name the keyboard focused row after an empty search is cleared', async () => { + GridFunctions.clickExcelFilterIcon(fix, 'ProductName'); + await fix.whenStable(); + + const search = fix.debugElement.query(By.css('igx-excel-style-search')).componentInstance; + const settle = async () => { + await fix.whenStable(); + await search.virtualScroll.layoutComplete; + await fix.whenStable(); + }; + await settle(); + + const searchElement = GridFunctions.getExcelStyleSearchComponent(fix); + const list = search.list.element.nativeElement as HTMLElement; + const input = GridFunctions.getExcelStyleSearchComponentInput(fix, searchElement); + + // Search for something no row matches, then take the search back out. + const searchAndClear = async () => { + input.value = 'nothing matches this'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settle(); + + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBe(0); + + input.value = ''; + input.dispatchEvent(new Event('input', { bubbles: true })); + await settle(); + + expect(GridFunctions.getExcelStyleSearchComponentListItems(fix).length).toBeGreaterThan(0); + }; + + // An empty list is not the same height as a full one, so the first round leaves the + // viewport at a size it did not have when the menu opened. The second round is the + // one that brings the rows back to a window the list has already reported, and so + // has no reason to report again. + await searchAndClear(); + await searchAndClear(); + + list.focus(); + await settle(); + + expect(document.activeElement).toBe(list); + + list.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await settle(); + + // The row the keyboard moved to is the one the focus is drawn on, and it is the row + // the listbox has to name - not merely some name that is not empty. + const focused = list.querySelector('.igx-list__item-base--active') as HTMLElement; + expect(focused).toBeTruthy(); + expect(list.getAttribute('aria-activedescendant')).toBe(focused.id); + expect(searchElement.querySelector(`#${focused.id}`)).toBe(focused); + }); +}); + describe('IgxGrid - Custom Filtering Strategy #grid', () => { let fix: ComponentFixture; let grid: IgxGridComponent; diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts index 010e01cca71..607e3a9fb1e 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-grid.spec.ts @@ -390,9 +390,10 @@ describe('IgxPivotGrid #pivotGrid', () => { headerRow = fixture.nativeElement.querySelector('igx-pivot-header-row'); - //Ensure for of update of cells. + //Ensure the igxFor containers of the grid itself updated. The Excel style filter's + //search list is virtualized by igx-virtual-scroll, so it contributes none. const headerDisplayContainers = headerRow.querySelectorAll('igx-display-container'); - expect(headerDisplayContainers.length).toEqual(5); + expect(headerDisplayContainers.length).toEqual(4); expect(headerDisplayContainers[0].children.length).toEqual(1); expect(headerDisplayContainers[0].innerText).toEqual('chevron_right\nAll Countries'); expect(headerDisplayContainers[1].children.length).toEqual(2); diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html index 55fcc8d7750..d8861085ddd 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html @@ -56,6 +56,11 @@ +@let itemWindow = data! + | comboFiltering:filterValue:displayKey:filteringOptions:filterFunction:disableFiltering + | comboGrouping:groupKey:valueKey:groupSortingDirection:compareCollator + | comboDataWindow:totalItemCount:virtualStartIndex; + - - @if (item?.isHeader) { - - - } - - @if (!item?.isHeader) { - - - } - + + + + @if (item?.isHeader) { + + + } + + @if (!item?.isHeader) { + + + } + + +
@if (filteredData?.length === 0 || isAddButtonVisible()) { @@ -101,7 +111,7 @@ @if (isAddButtonVisible()) { + [attr.aria-label]="resourceStrings.igx_combo_addCustomValues_placeholder" [index]="itemWindow.totalCount"> diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index 98a3a07a68c..cb1d03e425e 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -1,5 +1,5 @@ import { AsyncPipe } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, DOCUMENT, DebugElement, ElementRef, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, DOCUMENT, DebugElement, ElementRef, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormControl, FormGroup, FormsModule, NgForm, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } from '@angular/forms'; import { By } from '@angular/platform-browser'; @@ -21,7 +21,7 @@ const CSS_CLASS_COMBO_DROPDOWN = 'igx-combo__drop-down'; const CSS_CLASS_DROPDOWN = 'igx-drop-down'; const CSS_CLASS_DROPDOWNLIST_SCROLL = 'igx-drop-down__list-scroll'; const CSS_CLASS_CONTENT = 'igx-combo__content'; -const CSS_CLASS_CONTAINER = 'igx-display-container'; +const CSS_CLASS_CONTAINER = 'igx-vs__content'; const CSS_CLASS_DROPDOWNLISTITEM = 'igx-drop-down__item'; const CSS_CLASS_TOGGLEBUTTON = 'igx-combo__toggle-button'; const CSS_CLASS_CLEARBUTTON = 'igx-combo__clear-button'; @@ -1029,12 +1029,11 @@ describe('IgxSimpleCombo', () => { fixture.detectChanges(); combo.toggle(); fixture.detectChanges(); - const dropdownItemsContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)).nativeElement; const dropDownContainer = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTAINER}`)).nativeElement; const listItems = dropDownContainer.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); expect(listItems.length).toEqual(0); - // Expect no items to be rendered in the virtual container - expect(dropdownItemsContainer.children[0].childElementCount).toEqual(0); + // No row is instantiated at all, whatever structure the list keeps around it. + expect(dropDownContainer.querySelectorAll('igx-combo-item').length).toEqual(0); // Expect the list child (NOT COMBO ITEM) to be a container with "The list is empty"; const emptyElem = fixture.debugElement.query(By.css('.igx-combo__empty')); expect(emptyElem).not.toBeNull(); @@ -1138,8 +1137,7 @@ describe('IgxSimpleCombo', () => { expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); // Scroll selected items out of view - combo.virtualScrollContainer.scrollTo(40); - await wait(); + await combo.virtualScrollContainer.scrollToIndex(40); fixture.detectChanges(); combo.handleClear(spyObj); expect(combo.selection).toEqual(undefined); @@ -1468,7 +1466,7 @@ describe('IgxSimpleCombo', () => { fixture.detectChanges(); spyOn(combo, 'onClick').and.callThrough(); - spyOn((combo as any).virtDir, 'scrollTo').and.callThrough(); + spyOn(combo.virtualScrollContainer, 'scrollToIndex').and.callThrough(); const toggleButton = fixture.debugElement.query(By.directive(IgxIconComponent)); expect(toggleButton).toBeDefined(); @@ -1478,7 +1476,7 @@ describe('IgxSimpleCombo', () => { expect(combo.collapsed).toBeFalsy(); expect(combo.onClick).toHaveBeenCalledTimes(1); - expect((combo as any).virtDir.scrollTo).toHaveBeenCalledWith(0); + expect(combo.virtualScrollContainer.scrollToIndex).toHaveBeenCalledWith(0); }); it('should close the dropdown with Alt + ArrowUp', fakeAsync(() => { @@ -3014,8 +3012,7 @@ describe('IgxSimpleCombo', () => { combo.select(combo.data[1][combo.valueKey]); // Scroll selected item out of view - combo.virtualScrollContainer.scrollTo(40); - await wait(300); + await combo.virtualScrollContainer.scrollToIndex(40); fixture.detectChanges(); input.nativeElement.focus(); @@ -3043,11 +3040,11 @@ describe('IgxSimpleCombo', () => { combo.toggle(); // scroll to selected item - combo.virtualScrollContainer.scrollTo(15); - await wait(30); + await combo.virtualScrollContainer.scrollToIndex(15); fixture.detectChanges(); - const selectedItem = combo.data[combo.data.length - 1]; + const selectedItem = combo.data.find(item => item[combo.valueKey] === 15); + expect(selectedItem).toBeDefined(); expect(combo.displayValue).toEqual(`${selectedItem[combo.displayKey]}`); })); it('should not clear input on blur when bound to remote data and item is selected', () => { @@ -3095,6 +3092,136 @@ describe('IgxSimpleCombo', () => { })); }); + describe('Reconciling the selection when the data changes: ', () => { + let host: IgxSimpleComboReconcileComponent; + + const settle = async () => { + await fixture.whenStable(); + await host.combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + }; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxSimpleComboReconcileComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(IgxSimpleComboReconcileComponent); + host = fixture.componentInstance; + combo = host.combo; + await settle(); + }); + + it('should keep the grouped selection focused after an append', async () => { + const selected = host.data()[1]; + combo.select(selected); + combo.open(); + await settle(); + + // Grouped, so the rendered collection carries headers and its indices are not + // the indices of the bound array. + combo.dropdown.navigateItem(3); + await settle(); + expect(combo.dropdown.focusedItem?.value).toBe(selected); + + host.data.update(items => [...items, { label: 'Gamma', group: 'C' }]); + await settle(); + + expect(combo.selection).toBe(selected); + expect(combo.dropdown.focusedItem?.value).toBe(selected); + }); + + it('should follow a keyed selection through a reorder of the same length', async () => { + const records = Array.from({ length: 4 }, (_, id) => ({ id, label: `Product ${id}` })); + host.groupKey.set(null); + host.valueKey.set('id'); + host.data.set(records); + await settle(); + + combo.select(3); + combo.open(); + await settle(); + combo.dropdown.navigateItem(3); + await settle(); + expect(combo.dropdown.focusedItem?.value).toBe(records[3]); + + host.data.set([...records].reverse()); + await settle(); + + expect(combo.selection).toBe(records[3]); + expect(combo.dropdown.focusedItem?.value).toBe(records[3]); + expect(combo.dropdown.focusedItem?.index).toBe(0); + expect(fixture.nativeElement.querySelector('.igx-drop-down__item--focused')?.textContent) + .toContain('Product 3'); + }); + + it('should focus the replacement record when keyed data is rebound as new objects', async () => { + const records = Array.from({ length: 4 }, (_, id) => ({ id, label: `Product ${id}` })); + host.groupKey.set(null); + host.valueKey.set('id'); + host.data.set(records); + await settle(); + + combo.select(3); + combo.open(); + await settle(); + combo.dropdown.navigateItem(3); + await settle(); + + const replacement = records.map(record => ({ ...record })).reverse(); + host.data.set(replacement); + await settle(); + + expect(combo.selection).toBe(replacement[0]); + expect(combo.dropdown.focusedItem?.value).toBe(replacement[0]); + expect(combo.dropdown.focusedItem?.index).toBe(0); + expect(fixture.nativeElement.querySelector('.igx-drop-down__item--focused')?.textContent) + .toContain('Product 3'); + }); + + it('should resolve the selection once for a keyed data assignment', async () => { + let reads = 0; + const records = Array.from({ length: 100 }, (_, id) => ({ + get id() { + reads++; + return id; + }, + label: `Product ${id}` + })); + + host.groupKey.set(null); + host.valueKey.set('id'); + host.data.set(records); + await settle(); + combo.select(99); + await settle(); + + // Counted, not timed: resolving the selection per record would search the whole + // collection again for each of them. + reads = 0; + host.data.set([...records]); + await settle(); + + expect(reads).toBeLessThan(1000); + }); + + it('should not move a remotely bound list when a page arrives', async () => { + spyOnProperty(combo, 'isRemote').and.returnValue(true); + combo.select(host.data()[1]); + combo.open(); + await settle(); + + const navigate = spyOn(combo.dropdown, 'navigateItem').and.callThrough(); + host.data.update(items => [...items, { label: 'Gamma', group: 'C' }]); + await settle(); + + expect(navigate).not.toHaveBeenCalled(); + }); + }); + + describe('Integration', () => { let grid: IgxGridComponent; @@ -3157,6 +3284,22 @@ describe('IgxSimpleCombo', () => { }); }); +@Component({ + template: ``, + imports: [IgxSimpleComboComponent], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class IgxSimpleComboReconcileComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public data = signal([{ label: 'Alpha', group: 'A' }, { label: 'Beta', group: 'B' }]); + public groupKey = signal('group'); + public valueKey = signal(null); +} + @Component({ template: ` diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts index ca683655673..67c41c0f066 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -5,12 +5,12 @@ import { takeUntil } from 'rxjs/operators'; import { CancelableEventArgs, IBaseCancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from 'igniteui-angular/core'; import { IgxButtonDirective } from 'igniteui-angular/directives'; -import { IgxForOfDirective } from 'igniteui-angular/directives'; +import { IgxVirtualItemDirective, IgxVirtualScrollComponent } from 'igniteui-angular/virtual-scroll'; import { IgxRippleDirective } from 'igniteui-angular/directives'; import { IgxTextSelectionDirective } from 'igniteui-angular/directives'; import { IgxInputGroupComponent, IgxInputDirective, IgxSuffixDirective } from 'igniteui-angular/input-group'; import { IgxIconComponent } from 'igniteui-angular/icon'; -import { IGX_COMBO_COMPONENT, IgxComboAddItemComponent, IgxComboAPIService, IgxComboBaseDirective, IgxComboDropDownComponent, IgxComboFilteringPipe, IgxComboGroupingPipe, IgxComboItemComponent } from 'igniteui-angular/combo'; +import { IGX_COMBO_COMPONENT, IgxComboAddItemComponent, IgxComboAPIService, IgxComboBaseDirective, IgxComboDataWindowPipe, IgxComboDropDownComponent, IgxComboFilteringPipe, IgxComboGroupingPipe, IgxComboItemComponent } from 'igniteui-angular/combo'; import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; /** Emitted when the Combo's selection has changed. */ @@ -64,7 +64,7 @@ export interface ISimpleComboSelectionChangingEventArgs extends ISimpleComboSele '(keydown.ArrowDown)': 'onArrowDown($any($event))', '(keydown.Alt.ArrowDown)': 'onArrowDown($any($event))' }, - imports: [IgxInputGroupComponent, IgxInputDirective, IgxTextSelectionDirective, IgxSuffixDirective, NgTemplateOutlet, IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, IgxForOfDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxComboFilteringPipe, IgxComboGroupingPipe] + imports: [IgxInputGroupComponent, IgxInputDirective, IgxTextSelectionDirective, IgxSuffixDirective, NgTemplateOutlet, IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, IgxVirtualScrollComponent, IgxVirtualItemDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxComboFilteringPipe, IgxComboGroupingPipe, IgxComboDataWindowPipe] }) export class IgxSimpleComboComponent extends IgxComboBaseDirective implements ControlValueAccessor, AfterViewInit, DoCheck { private platformUtil = inject(PlatformUtil); @@ -124,6 +124,9 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co private _collapsing = false; + /** The rendered collection the selection was last brought into focus against. */ + private _refocusedItems: readonly any[] | null = null; + /** @hidden @internal */ public get filteredData(): any[] | null { return this._filteredData; @@ -162,7 +165,7 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co event.stopPropagation(); this.open(); } else { - if (this.virtDir.igxForOf!.length > 0 && !this.hasSelectedItem) { + if (this.filteredData!.length > 0 && !this.hasSelectedItem) { this.dropdown.navigateNext(); this.dropdownContainer.nativeElement.focus(); } else if (this.allowCustomValues) { @@ -210,23 +213,6 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co /** @hidden @internal */ public override ngAfterViewInit(): void { - this.virtDir.contentSizeChange.pipe(takeUntil(this.destroy$)).subscribe(() => { - if (super.selection.length > 0) { - const index = this.virtDir.igxForOf!.findIndex(e => { - let current = e ? e[this.valueKey] : undefined; - if (this.valueKey === null || this.valueKey === undefined) { - current = e; - } - return current === super.selection[0]; - }); - if (!this.isRemote) { - // navigate to item only if we have local data - // as with remote data this will fiddle with igxFor's scroll handler - // and will trigger another chunk load which will break the visualization - this.dropdown.navigateItem(index); - } - } - }); this.dropdown.opening.pipe(takeUntil(this.destroy$)).subscribe((args) => { if (args.cancel) { return; @@ -274,6 +260,36 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co this._displayValue = this.createDisplayText(super.selection, []); this._value = this.valueKey ? super.selection.map(item => item[this.valueKey]) : super.selection; } + this.refocusSelection(); + } + + /** + * Keeps the selected record focused once the rendered collection has been rebuilt. + * `navigateItem` addresses that collection, whose indices are not the bound array's. + */ + private refocusSelection(): void { + const items = this.virtualScrollContainer?.dataWindow()?.items; + if (!items || items === this._refocusedItems) { + return; + } + this._refocusedItems = items; + + // A page arriving for a remote list would otherwise move the scroll and ask for the + // next one in the middle of the consumer supplying it. + if (this.isRemote) { + return; + } + + // Once for the whole operation: this getter searches the collection. + const selection = super.selection; + if (selection.length === 0) { + return; + } + + const index = items.indexOf(selection[0]); + if (index >= 0) { + this.dropdown?.navigateItem(index); + } } /** @hidden @internal */ @@ -497,7 +513,7 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co public override onClick(event: MouseEvent): void { super.onClick(event); if (this.comboInput.value.length === 0) { - this.virtDir.scrollTo(0); + void this.virtualScrollContainer?.scrollToIndex(0); } } diff --git a/projects/igniteui-angular/test-utils/grid-functions.spec.ts b/projects/igniteui-angular/test-utils/grid-functions.spec.ts index 790b80b8898..5bb9d37dfb9 100644 --- a/projects/igniteui-angular/test-utils/grid-functions.spec.ts +++ b/projects/igniteui-angular/test-utils/grid-functions.spec.ts @@ -1084,8 +1084,8 @@ export class GridFunctions { public static getExcelStyleSearchComponentScrollbar(fix, menu = null) { const searchComponent = GridFunctions.getExcelStyleSearchComponent(fix, menu); - const scrollbar = searchComponent.querySelector('igx-virtual-helper'); - return scrollbar; + // The virtual scroll host is the scrolling element of the search list. + return searchComponent.querySelector('igx-virtual-scroll'); } public static getExcelStyleSearchComponentInput(fix, comp = null, grid = 'igx-grid'): HTMLInputElement { diff --git a/projects/igniteui-angular/virtual-scroll/README.md b/projects/igniteui-angular/virtual-scroll/README.md index 142de11cbe6..1d0e0c5a6bf 100644 --- a/projects/igniteui-angular/virtual-scroll/README.md +++ b/projects/igniteui-angular/virtual-scroll/README.md @@ -39,10 +39,89 @@ export class MyComponent { | Input | Type | Default | Description | |---|---|---|---| | `data` | `T[]` | `[]` | The array of items to virtualize. Compared by reference. See [Updating `data`](#updating-data). | +| `dataWindow` | `VirtualDataWindow \| null` | `null` | A loaded page of a larger collection. Takes the place of `data` while it is set. See [Paged data](#paged-data). | | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Scroll axis. | | `overScan` | `number` | `2` | Extra items to render beyond each edge of the viewport. Higher values reduce blank flashes during fast scrolling at the cost of slightly more DOM nodes. Normalized to a non-negative integer. | | `estimatedItemSize` | `number` | `50` | Pixel size used for items before they are measured in the DOM. Set this close to the real average size for the best initial-render accuracy. A non-positive value falls back to `50`. | | `itemTemplate` | `TemplateRef> \| null` | `null` | Programmatic template that takes precedence over a content `ng-template[igxVirtualItem]`. | +| `initialViewportSize` | `number` | `0` | Viewport size in pixels to render the **first** window against, for a list that cannot be measured when it is first rendered. A hint for that render only: once the host has been laid out its own size takes over, zero included, and this input is not read again. Negative, `NaN` and infinite values count as no hint. See [Lists inside a popup](#lists-inside-a-popup). | + + +### Paged data + +For data that arrives a page at a time, bind `dataWindow` instead of `data`: + +```ts +interface VirtualDataWindow { + readonly items: readonly T[]; // The loaded page + readonly startIndex: number; // The index items[0] has in the whole collection + readonly totalCount: number; // How many items the whole collection has +} +``` + +The list is as long as `totalCount`, so the scrollbar spans the whole collection while only +the page is in memory. An index in the list is an index in that collection: the item at +`index` is `items[index - startIndex]`, and `IgxVsItemContext.index` and `.count` are the +global index and the total. Indices the page does not cover render nothing, so no template +is instantiated for an item that has not arrived. + +`stateChange` reports the range the viewport wants, which is what a consumer supplies the +next page from: + +```ts +load(state: VirtualScrollState) { + const startIndex = state.startIndex; + this.service.fetch(startIndex, state.endIndex - startIndex + 1) + .subscribe(page => this.window = { items: page.rows, startIndex, totalCount: page.total }); +} +``` + +Sizes are measured and kept per index, and the rows a new page renders are measured again in +the DOM, so moving the window costs the page rather than the collection. This assumes the +indexing stays stable while paging: a sort or a filter that puts different records at the same +indices leaves the sizes measured for the previous ones in place, for the indices that are not +re-rendered. + +`dataRequest` is not emitted in this mode — it asks for items to append, which a sized +collection does not need. + +Paging keeps the *items* down to a page, not the size bookkeeping. The engine holds one size +entry per index, so its memory grows with `totalCount` rather than with the page: roughly +17 MB per million items. Give `totalCount` the size of the collection the consumer really +pages through; a value far beyond what the platform can allocate fails at the allocation. + +### Lists inside a popup + +A list inside a drop-down, dialog or any other container that is hidden until it opens has +no size to measure in the change detection pass that reveals it. The component learns its +size from a `ResizeObserver` and from `afterNextRender`, both of which run after a render, +so that first render is laid out against a viewport of zero and produces no rows. In a Karma +reproduction of a list revealed by a single synchronous pass, it stayed empty for two +`requestAnimationFrame` iterations before filling in. + +A wrapper that reacts to whether the list has children can flip state between those passes, +which Angular reports as `NG0100` in development mode. + +Pass the size the container gives the list and the first window renders with it: + +```html + + {{ item }} + +``` + +The value is a starting point, not an override. Once the host has been laid out its own size +is the only one used, and later resizes are followed normally. A host that is laid out at zero +height reports zero, and the list renders nothing, which is correct for a collapsed container. + +Changing `orientation` starts the new axis with no measurement of its own — a height measured +on the vertical axis says nothing about the width the horizontal one will have — so the hint +applies again for the first render on that axis. + +A host with no box at all — hidden or detached — is not measured, because the zero it reports +says nothing about how large it will be once shown. Its last measurement is kept so the list +renders its window in the pass that reveals it again. The deliberate consequence is that the +rendered window stays in the DOM while the host is away. Changing `estimatedItemSize` re-applies it to every item that has **not** yet been measured in the DOM. Items that have been measured keep their real size. @@ -52,7 +131,7 @@ Changing `estimatedItemSize` re-applies it to every item that has **not** yet be | Output | Payload | Description | |---|---|---| -| `stateChange` | `VirtualScrollState` | Emitted when the rendered virtual window changes. Consecutive renders that produce an identical window are not re-emitted. | +| `stateChange` | `VirtualScrollState` | Emitted when the virtual window changes. It reports the range the viewport wants, over-scan included; with `dataWindow` bound that range can reach past the loaded page, so it is not always the set of rows in the DOM. Consecutive renders that produce an identical window are not re-emitted. | | `dataRequest` | `VirtualScrollDataRequest` | Emitted when the rendered window comes within a few items of the end of `data`. Use this to implement infinite / remote scrolling. | --- @@ -132,13 +211,17 @@ Marks an `ng-template` as the item template for the nearest `igx-virtual-scroll` ```ts interface VirtualScrollState { - startIndex: number; // First rendered item index - endIndex: number; // Last rendered item index (inclusive) + startIndex: number; // First item index of the wanted range + endIndex: number; // Last item index of the wanted range (inclusive) viewportSize: number; // Viewport height (or width) in px totalSize: number; // Total virtual content size in px } ``` +The range is what the viewport wants, the over-scan buffer included. Bound to `data` that is +the set of rows in the DOM. Bound to `dataWindow` it is the range to load next, and the rows +actually rendered are its intersection with the page - which can be narrower, or empty. + ### `VirtualScrollDataRequest` ```ts @@ -197,7 +280,7 @@ loadMore(req: VirtualScrollDataRequest) { } ``` -`dataRequest` is also emitted on the **first render** when the initially loaded items do not fill the viewport, so an empty or short initial `data` array is enough to start the loading chain. +`dataRequest` is also emitted on the **first render** when the initially loaded items do not fill the viewport, so a short initial `data` array is enough to start the loading chain. An **empty** array is not: with nothing loaded there is no rendered window to run out of, so load the first page yourself and let `dataRequest` carry the rest. Only one request is in flight at a time: the next one is emitted after `data` changes. If your source is exhausted and you reassign `data` without adding items, the component will not ask again for the same `startIndex`. diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts index 14831ad044a..3c6313b2dcf 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/types.ts @@ -52,6 +52,23 @@ export interface VirtualScrollState extends VisibleRange { totalSize: number; } +/** + * A loaded page of a larger collection, for data that arrives a page at a time. + * + * The virtual scroll sizes and addresses the list by `totalCount`, while only `items` are + * in memory. An index in the list is an index in the whole collection, so the item at + * `index` is `items[index - startIndex]`, and indices the page does not cover render + * nothing until a page that covers them arrives. + */ +export interface VirtualDataWindow { + /** The loaded items. */ + readonly items: readonly T[]; + /** The index `items[0]` has in the whole collection. */ + readonly startIndex: number; + /** How many items the whole collection has. */ + readonly totalCount: number; +} + /** * Request for more data, emitted when the rendered window nears the end of * the loaded items. Listen to it to implement infinite / remote scrolling. diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts index 2ffca51afca..e5354845d76 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.spec.ts @@ -5,6 +5,7 @@ import { By } from '@angular/platform-browser'; import { VirtualScrollEngine } from './scroll-engine'; import { IgxVsItemContext, + VirtualDataWindow, VirtualScrollDataRequest, VirtualScrollState, } from './types'; @@ -522,6 +523,82 @@ class TestHostComponent { } } +@Component({ + selector: 'test-virtual-scroll-window', + template: ` + + + {{ i }}:{{ count }}:{{ item }} + + + `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestWindowHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + + public items = signal([]); + public window = signal | null>(null); + public rowHeight = signal(50); + public states: VirtualScrollState[] = []; + public requests: VirtualScrollDataRequest[] = []; + + public pageAt(startIndex: number, count = 20, totalCount = 1000): VirtualDataWindow { + return { + items: Array.from({ length: count }, (_, i) => `Item ${startIndex + i}`), + startIndex, + totalCount, + }; + } + + /** A page of fresh objects, the way a deserialized response arrives. */ + public objectPageAt(startIndex: number, count = 20): VirtualDataWindow { + return { + items: Array.from({ length: count }, (_, i) => ({ id: startIndex + i })), + startIndex, + totalCount: 1000, + }; + } +} + +@Component({ + selector: 'test-virtual-scroll-popup', + template: ` +
+ + + {{ i }}: {{ item }} + + +
+ `, + imports: [IgxVirtualScrollComponent, IgxVirtualItemDirective], +}) +class TestPopupHostComponent { + public readonly vs = viewChild.required(IgxVirtualScrollComponent); + + public items = signal(generateItems(100)); + public initialViewportSize = signal(0); + public hostHeight = signal(300); + public open = signal(false); +} + @Component({ selector: 'test-virtual-scroll-rtl', template: ` @@ -644,6 +721,8 @@ describe('IgxVirtualScrollComponent', () => { TestRtlHostComponent, TestNoTemplateHostComponent, TestProgrammaticTemplateComponent, + TestPopupHostComponent, + TestWindowHostComponent, ], }).compileComponents(); })); @@ -762,6 +841,371 @@ describe('IgxVirtualScrollComponent', () => { }); }); + describe('initial viewport size', () => { + let popup: ComponentFixture; + let popupHost: TestPopupHostComponent; + let popupScroll: IgxVirtualScrollComponent; + + /** Creates the fixture with the list hidden, the way a closed drop-down holds one. */ + async function createPopup(initialViewportSize = 0): Promise { + popup = TestBed.createComponent(TestPopupHostComponent); + popupHost = popup.componentInstance; + popupHost.initialViewportSize.set(initialViewportSize); + popup.detectChanges(); + popupScroll = popupHost.vs() as IgxVirtualScrollComponent; + } + + /** Shows the list in one synchronous pass, the way opening a drop-down does. */ + function reveal(): void { + popupHost.open.set(true); + popup.detectChanges(); + } + + /** Settles repeatedly until `predicate` holds, so a resize report is not raced. */ + async function settleUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 20 && !predicate(); i++) { + await settle(popup, popupScroll); + } + } + + it('should render nothing in the pass that reveals it when the input is omitted', async () => { + await createPopup(); + reveal(); + + expect(vsItems(popup).length).toBe(0); + }); + + it('should render the first window in the pass that reveals it', async () => { + await createPopup(300); + reveal(); + + // A 300px viewport of 50px rows shows 0..6, plus an over-scan of 2. + expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should let the measured size replace an initial value that was too large', async () => { + await createPopup(2000); + reveal(); + await settleUntil(() => vsItems(popup).length === 9); + + // The host is 300px, so the window settles at what it really holds rather than + // the 40 rows 2000px would. + expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should follow a later resize of the host', async () => { + await createPopup(300); + reveal(); + expect(vsIndices(popup)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + + popupHost.hostHeight.set(600); + await settleUntil(() => vsItems(popup).length > 9); + + // 600px of 50px rows shows 0..12, plus an over-scan of 2. + expect(Math.max(...vsIndices(popup))).toBe(14); + }); + + it('should keep the last measured size when the host is hidden', async () => { + // The hint and the host disagree, so the window says which one is in use: the + // 300px hint gives 9 rows, the 600px host gives 15. + await createPopup(300); + popupHost.hostHeight.set(600); + reveal(); + await settleUntil(() => vsItems(popup).length === 15); + expect(vsItems(popup).length).toBe(15); + + // A value that would be unmistakable if the input were read again. + popupHost.initialViewportSize.set(2000); + popupHost.open.set(false); + await settle(popup, popupScroll); + + expect(vsItems(popup).length).toBe(15); + }); + + it('should render nothing for a host that is laid out with no size', async () => { + await createPopup(300); + popupHost.hostHeight.set(0); + reveal(); + await settleUntil(() => vsItems(popup).length === 0); + + // Collapsed by its own layout rather than hidden, so zero is its real size and + // the hint has no say in it. + expect(vsItems(popup).length).toBe(0); + }); + + it('should collapse when a measured host is later given no size', async () => { + await createPopup(300); + popupHost.hostHeight.set(600); + reveal(); + await settleUntil(() => vsItems(popup).length === 15); + + popupHost.hostHeight.set(0); + await settleUntil(() => vsItems(popup).length === 0); + + expect(vsItems(popup).length).toBe(0); + }); + + it('should not start empty when the host is shown again', async () => { + await createPopup(300); + popupHost.hostHeight.set(600); + reveal(); + await settleUntil(() => vsItems(popup).length === 15); + + popupHost.open.set(false); + await settle(popup, popupScroll); + reveal(); + + expect(vsItems(popup).length).toBe(15); + }); + + for (const [label, value] of [ + ['negative', -300], + ['NaN', Number.NaN], + ['infinite', Number.POSITIVE_INFINITY], + ] as [string, number][]) { + it(`should treat a ${label} initial size as no hint at all`, async () => { + await createPopup(value); + reveal(); + + expect(vsItems(popup).length).toBe(0); + }); + } + }); + + describe('windowed data', () => { + let windowFixture: ComponentFixture; + let windowHost: TestWindowHostComponent; + let windowScroll: IgxVirtualScrollComponent; + + async function createWindowFixture(): Promise { + windowFixture = TestBed.createComponent(TestWindowHostComponent); + windowHost = windowFixture.componentInstance; + windowFixture.autoDetectChanges(); + await windowFixture.whenStable(); + windowScroll = windowHost.vs() as IgxVirtualScrollComponent; + await settle(windowFixture, windowScroll); + } + + async function bindWindow(window: VirtualDataWindow | null): Promise { + windowHost.window.set(window); + await settle(windowFixture, windowScroll); + } + + beforeEach(async () => { + await createWindowFixture(); + }); + + it('should behave like an ordinary array when no window is bound', async () => { + windowHost.items.set(generateItems(40)); + await settle(windowFixture, windowScroll); + + expect(vsTrack(windowFixture).style.height).toBe(`${40 * 50}px`); + expect(vsIndices(windowFixture)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should size the track from the whole collection', async () => { + await bindWindow(windowHost.pageAt(0)); + + expect(vsTrack(windowFixture).style.height).toBe(`${1000 * 50}px`); + }); + + it('should render a page that starts further in at its own indices', async () => { + await bindWindow(windowHost.pageAt(400)); + await windowScroll.scrollToIndex(400); + await settle(windowFixture, windowScroll); + + const rendered = vsIndices(windowFixture); + expect(Math.min(...rendered)).toBeGreaterThanOrEqual(400); + expect(Math.max(...rendered)).toBeLessThanOrEqual(419); + expect(windowFixture.nativeElement.textContent).toContain('Item 400'); + }); + + it('should report the whole collection as the item count', async () => { + await bindWindow(windowHost.pageAt(0)); + + // The template renders "index:count:item". + expect(windowFixture.nativeElement.textContent).toContain('0:1000:Item 0'); + }); + + it('should not render rows for indices the page does not cover', async () => { + // The rendered range sits at the top of the collection, the page does not. + await bindWindow(windowHost.pageAt(400)); + + expect(vsItems(windowFixture).length).toBe(0); + }); + + it('should scroll to an index beyond the loaded page', async () => { + await bindWindow(windowHost.pageAt(0)); + await windowScroll.scrollToIndex(900); + await settle(windowFixture, windowScroll); + + expect(vsElement(windowFixture).scrollTop).toBeGreaterThan(0); + }); + + it('should keep the measured sizes when the page moves within the collection', async () => { + await bindWindow(windowHost.pageAt(0)); + const resizeSpy = spyOn(engineOf(windowScroll), 'resize').and.callThrough(); + + await bindWindow(windowHost.pageAt(400)); + + // Nothing discarded: the indices still mean what they did, and the rows that + // are rendered are measured again in the DOM. + expect(resizeSpy.calls.mostRecent().args).toEqual([1000, 50, 1000]); + }); + + it('should not do work proportional to the collection when a page is re-fetched', async () => { + await bindWindow(windowHost.objectPageAt(0)); + const resizeSpy = spyOn(engineOf(windowScroll), 'resize').and.callThrough(); + + // The same records again as new objects, the way a deserialized response arrives. + await bindWindow(windowHost.objectPageAt(0)); + + expect(resizeSpy.calls.mostRecent().args).toEqual([1000, 50, 1000]); + }); + + it('should resize the track when the collection size changes', async () => { + await bindWindow(windowHost.pageAt(0)); + expect(vsTrack(windowFixture).style.height).toBe(`${1000 * 50}px`); + + await bindWindow(windowHost.pageAt(0, 20, 400)); + + expect(vsTrack(windowFixture).style.height).toBe(`${400 * 50}px`); + }); + + it('should go back to the ordinary array when the window is cleared', async () => { + await bindWindow(windowHost.pageAt(400)); + + windowHost.items.set(generateItems(40)); + await bindWindow(null); + + expect(vsTrack(windowFixture).style.height).toBe(`${40 * 50}px`); + expect(vsIndices(windowFixture)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]); + }); + + it('should ask for appended data again after the window is cleared', async () => { + windowHost.items.set(generateItems(10)); + await settle(windowFixture, windowScroll); + expect(windowHost.requests.length).toBe(1); + + await bindWindow(windowHost.pageAt(0)); + windowHost.requests.length = 0; + + // Back to the same array: the request the plain path had already made must not + // stand in the way of making it again. + await bindWindow(null); + + expect(windowHost.requests.length).toBe(1); + }); + + it('should measure a page that arrives after the list has scrolled to it', async () => { + // The order a remote list actually goes in: a page is loaded, the list scrolls + // past it, and the page covering where it landed arrives afterwards. + await bindWindow(windowHost.pageAt(0)); + await windowScroll.scrollToIndex(400); + await settle(windowFixture, windowScroll); + expect(vsItems(windowFixture).length).toBe(0); + + windowHost.rowHeight.set(80); + await bindWindow(windowHost.pageAt(400)); + + // The rows that appeared have to be measured, or the collection keeps the + // estimate for them and the scrollbar stays wrong. + expect(vsItems(windowFixture).length).toBeGreaterThan(0); + expect(engineOf(windowScroll).totalSize()).toBeGreaterThan(1000 * 50); + }); + + it('should report the range it needs and render it once that page arrives', async () => { + await bindWindow(windowHost.pageAt(0)); + windowHost.states.length = 0; + + await windowScroll.scrollToIndex(400); + await settle(windowFixture, windowScroll); + + const wanted = windowHost.states.at(-1)!; + expect(wanted.startIndex).toBeGreaterThan(390); + expect(wanted.endIndex).toBeGreaterThanOrEqual(wanted.startIndex); + + const count = wanted.endIndex - wanted.startIndex + 1; + await bindWindow(windowHost.pageAt(wanted.startIndex, count)); + + expect(vsIndices(windowFixture)).toContain(wanted.startIndex); + expect(windowFixture.nativeElement.textContent) + .toContain(`Item ${wanted.startIndex}`); + }); + + it('should report a moved range whose loaded part has not changed', async () => { + // Two loaded rows, and a viewport that reaches well past both of them. Moving + // one row down changes the range the consumer is being asked for, while the + // part of it that has data behind it stays exactly the same. + await bindWindow({ + items: ['Item 400', 'Item 401'], + startIndex: 400, + totalCount: 1000, + }); + + await windowScroll.scrollToIndex(400); + await settle(windowFixture, windowScroll); + + const first = windowHost.states.at(-1)!; + + await windowScroll.scrollToIndex(401); + await settle(windowFixture, windowScroll); + + // The same two rows are rendered either way, so nothing about the DOM says the + // request moved. The consumer loads pages from what it is told here. + expect(vsIndices(windowFixture)).toEqual([400, 401]); + expect(windowHost.states.at(-1)!.startIndex).toBe(first.startIndex + 1); + }); + + for (const [label, value, normalized] of [ + ['NaN', Number.NaN, 0], + ['infinite', Number.POSITIVE_INFINITY, 0], + ['negative', -400, 0], + ['fractional', 400.7, 400], + ] as [string, number, number][]) { + it(`should normalize a ${label} start index`, async () => { + await bindWindow({ + items: generateItems(20), + startIndex: value, + totalCount: 1000, + }); + + expect(vsTrack(windowFixture).style.height).toBe(`${1000 * 50}px`); + + await windowScroll.scrollToIndex(normalized); + await settle(windowFixture, windowScroll); + + // Only the page has data behind it, so the first rendered index is where the + // page begins - which is the normalized start index and nothing else. + expect(vsItems(windowFixture).length).toBeGreaterThan(0); + expect(Math.min(...vsIndices(windowFixture))).toBe(normalized); + }); + + it(`should normalize a ${label} total count`, async () => { + await bindWindow({ + items: generateItems(20), + startIndex: 0, + totalCount: value, + }); + + // A page is trusted to be no longer than the collection it belongs to, so a + // count that normalizes below the page it carries is raised to that page. + const total = Math.max(normalized, 20); + expect(vsTrack(windowFixture).style.height).toBe(`${total * 50}px`); + }); + } + + it('should not ask for appended data while a window is bound', async () => { + await bindWindow(windowHost.pageAt(0)); + windowHost.requests.length = 0; + + await windowScroll.scrollToIndex(999); + await settle(windowFixture, windowScroll); + + expect(windowHost.requests).toEqual([]); + }); + }); + describe('orientation', () => { beforeEach(async () => { await createFixture(); diff --git a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts index e943713ebff..04f0af9bccd 100644 --- a/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts +++ b/projects/igniteui-angular/virtual-scroll/src/virtual-scroll/virtual-scroll.component.ts @@ -25,6 +25,7 @@ import { VirtualScrollEngine } from "./scroll-engine"; import { IgxVsItemContext, ScrollAlignment, + VirtualDataWindow, VirtualScrollDataRequest, VirtualScrollState, VisibleRange, @@ -58,6 +59,21 @@ const LAYOUT_FRAME_TIMEOUT_MS = 100; const EMPTY_RANGE: VisibleRange = Object.freeze({ startIndex: 0, endIndex: -1 }); +/** `data` and `dataWindow` seen as one thing: what is loaded, and where it sits. */ +interface LoadedItems { + items: readonly T[]; + startIndex: number; + totalCount: number; + /** Whether this came from `dataWindow`, which is a page of something larger. */ + windowed: boolean; +} + +/** A consumer-supplied index or count, reduced to a whole non-negative number. */ +function toCount(value: number): number { + const count = Math.trunc(Number(value)); + return Number.isFinite(count) ? Math.max(0, count) : 0; +} + function rangesEqual(a: VisibleRange, b: VisibleRange): boolean { return a.startIndex === b.startIndex && a.endIndex === b.endIndex; } @@ -152,10 +168,11 @@ export class IgxVirtualScrollComponent implements OnDestroy { /** Bumped only when a scroll actually moves the rendered window. */ private readonly _scrollTick = signal(0); - private readonly _viewportSize = signal(0); + /** The measured viewport, or `null` while the host has never been laid out. */ + private readonly _viewportSize = signal(null); - /** The `data` array as of the previous change, for `_firstChangedIndex`. */ - private _previousData: T[] | undefined; + /** What was loaded as of the previous change, for `_retainCount`. */ + private _previousItems: LoadedItems | undefined; private _lastEmittedState: VirtualScrollState | null = null; private _hasPendingDataRequest = false; @@ -213,6 +230,39 @@ export class IgxVirtualScrollComponent implements OnDestroy { */ public readonly estimatedItemSize = input(DEFAULT_ESTIMATED_ITEM_SIZE); + /** + * A loaded page of a larger collection, for data that arrives a page at a time. + * + * Takes the place of `data` while it is set. The list is as long as `totalCount`, so the + * scrollbar spans the whole collection while only the page is in memory. Indices the page + * does not cover render nothing; use `stateChange` to see which range is wanted and supply + * the page that covers it. + * + * @example + * ```html + * + * {{ item?.name }} + * + * ``` + */ + public readonly dataWindow = input | null>(null); + + /** + * Viewport size in pixels to render the first window against, for a list that is hidden + * until the change detection pass that reveals it and so has no size to measure in it. + * + * A hint for that first render only: once the host has been laid out its own size takes + * over, zero included. Negative, `NaN` and infinite values count as no hint. + * + * @example + * ```html + * + * {{ item }} + * + * ``` + */ + public readonly initialViewportSize = input(0); + /** * Item template provided programmatically. Takes precedence over a content * `ng-template[igxVirtualItem]` when both are provided. @@ -256,8 +306,38 @@ export class IgxVirtualScrollComponent implements OnDestroy { () => this.itemTemplate() ?? this._itemDirective()?.template ?? null, ); - /** `data`, guarded against a nullish value set by the consumer. */ - private readonly _items = computed(() => this.data() ?? []); + /** + * What the component has to work with, from whichever data input is in use. A page is + * trusted to be no longer than the collection it says it belongs to. + */ + private readonly _loaded = computed>(() => { + const window = this.dataWindow(); + + if (!window) { + const items = this.data() ?? []; + return { items, startIndex: 0, totalCount: items.length, windowed: false }; + } + + const items = window.items ?? []; + const startIndex = toCount(window.startIndex); + return { + items, + startIndex, + totalCount: Math.max(toCount(window.totalCount), startIndex + items.length), + windowed: true, + }; + }); + + /** `initialViewportSize`, normalized to a non-negative number. */ + private readonly _normalizedInitialViewportSize = computed(() => { + const value = Number(this.initialViewportSize()); + return Number.isFinite(value) ? Math.max(0, value) : 0; + }); + + /** The measured size once the host has been laid out, the hint until then. */ + private readonly _effectiveViewportSize = computed( + () => this._viewportSize() ?? this._normalizedInitialViewportSize(), + ); /** The configured `overScan`, normalized to a non-negative integer. */ private readonly _normalizedOverScan = computed(() => { @@ -293,7 +373,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { return this._resolvedTemplate() ? this._engine.getVisibleRange( this._scrollPosition, - this._viewportSize(), + this._effectiveViewportSize(), this._normalizedOverScan(), ) : EMPTY_RANGE; @@ -304,14 +384,28 @@ export class IgxVirtualScrollComponent implements OnDestroy { /** The track size, in DOM space. */ protected readonly _spaceSize = this._engine.domSize; + /** The part of the rendered range a page actually covers. */ + private readonly _loadedRange = computed( + () => { + const { startIndex, endIndex } = this._visibleRange(); + const { items, startIndex: from } = this._loaded(); + + return { + startIndex: Math.max(startIndex, from), + endIndex: Math.min(endIndex, from + items.length - 1), + }; + }, + { equal: rangesEqual }, + ); + /** The item contexts for the currently rendered window, in render order. */ protected readonly _renderedItems = computed[]>(() => { - const { startIndex, endIndex } = this._visibleRange(); - const items = this._items(); + const { startIndex, endIndex } = this._loadedRange(); + const { items, startIndex: from, totalCount } = this._loaded(); const rendered: IgxVsItemContext[] = []; for (let i = startIndex; i <= endIndex; i++) { - rendered.push(new IgxVsItemContext(items[i], i, items.length)); + rendered.push(new IgxVsItemContext(items[i - from], i, totalCount)); } return rendered; }); @@ -325,7 +419,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { // The offsets below are plain reads of the engine's size state, so depend // on its version explicitly. this._engine.version(); - const range = this._visibleRange(); + const range = this._loadedRange(); // Under coordinate compression item positions are scaled down but item // sizes are not. Without this cap the rendered range would overflow past @@ -353,15 +447,20 @@ export class IgxVirtualScrollComponent implements OnDestroy { // Sync the engine's item count with `data`, discarding the measurements // of items whose identity changed. effect(() => { - const items = this._items(); + const loaded = this._loaded(); untracked(() => { - const previous = this._previousData; - this._previousData = items; + const previous = this._previousItems; + const switched = !!previous && previous.windowed !== loaded.windowed; + this._previousItems = loaded; this._engine.resize( - items.length, + loaded.totalCount, this._normalizedItemSize(), - this._firstChangedIndex(previous, items), + this._retainCount(previous, loaded), ); + // The count the other input had reached says nothing about this one. + if (switched) { + this._lastDataRequestIndex = -1; + } // New data (or a reset) clears any in-flight data request so the next // approach to the end of the list can emit again. this._hasPendingDataRequest = false; @@ -383,6 +482,8 @@ export class IgxVirtualScrollComponent implements OnDestroy { return; } + // The size of the previous axis says nothing about the new one. + this._viewportSize.set(null); this._measureViewport(); this._scrollPosition = this._currentAxisScroll(); this._scrollTick.update((v) => v + 1); @@ -400,7 +501,11 @@ export class IgxVirtualScrollComponent implements OnDestroy { // the window or the engine's sizes change. afterRenderEffect({ read: () => { + // The wanted range and the rows behind it are separate dependencies once a + // window is bound: the viewport can move while the part of it the page covers + // stays identical. Notifications follow the first, measurement the second. this._visibleRange(); + this._renderedItems(); this._engine.version(); untracked(() => { this._scheduleItemMeasurement(); @@ -446,7 +551,11 @@ export class IgxVirtualScrollComponent implements OnDestroy { index: number, options?: ScrollIntoViewOptions, ): Promise { - const clampedIndex = clamp(index, 0, Math.max(0, this._items().length - 1)); + const clampedIndex = clamp( + index, + 0, + Math.max(0, this._loaded().totalCount - 1), + ); // A newer call supersedes a correction loop that still runs for a // previous call, for example under rapid, repeated calls. @@ -528,7 +637,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { if ( requested === "nearest" && - this._engine.isIndexInView(index, current, this._viewportSize()) + this._engine.isIndexInView(index, current, this._effectiveViewportSize()) ) { return current; } @@ -538,7 +647,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { return this._engine.getAlignedScrollOffset( index, - this._viewportSize(), + this._effectiveViewportSize(), align, ); } @@ -692,8 +801,17 @@ export class IgxVirtualScrollComponent implements OnDestroy { private _measureViewport(): void { const host = this._hostRef.nativeElement; - const size = this._isVertical() ? host.clientHeight : host.clientWidth; + // A host with no box at all reports zero without having a size: it is hidden or + // detached, and it will have one again when it is shown. Its last measurement is kept + // so that it renders in the pass that reveals it, at the cost of leaving the window + // rendered while it is away. A host that is laid out reports its real size, and a + // laid-out zero is a size like any other. + if (!host.isConnected || host.getClientRects().length === 0) { + return; + } + + const size = this._isVertical() ? host.clientHeight : host.clientWidth; if (size !== untracked(this._viewportSize)) { this._viewportSize.set(size); } @@ -733,7 +851,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { const next = this._engine.getVisibleRange( this._scrollPosition, - untracked(this._viewportSize), + untracked(this._effectiveViewportSize), untracked(this._normalizedOverScan), ); @@ -819,11 +937,10 @@ export class IgxVirtualScrollComponent implements OnDestroy { * matches its rendered content. An append (the `dataRequest` flow) retains * all items. A filter or a replacement retains only the unchanged prefix. */ - private _firstChangedIndex(previous: T[] | undefined, current: T[]): number { - if (!previous) { - return 0; - } - + private _firstChangedIndex( + previous: readonly T[], + current: readonly T[], + ): number { const shared = Math.min(previous.length, current.length); for (let i = 0; i < shared; i++) { if (previous[i] !== current[i]) { @@ -833,6 +950,25 @@ export class IgxVirtualScrollComponent implements OnDestroy { return shared; } + /** + * How many leading items keep their measured size across a change. A page keeps all of + * them, because its indices still mean the same records and the rendered rows are + * measured again in the DOM; comparing it item by item would only see the fresh objects + * a service hands back. Switching inputs keeps none. + */ + private _retainCount( + previous: LoadedItems | undefined, + current: LoadedItems, + ): number { + if (!previous || previous.windowed !== current.windowed) { + return 0; + } + + return current.windowed + ? current.totalCount + : this._firstChangedIndex(previous.items, current.items); + } + /** * Emits `stateChange`. Skipped when the window is empty or equal to the * last reported one, because measurement passes re-render without a window @@ -847,7 +983,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { const state: VirtualScrollState = { startIndex, endIndex, - viewportSize: untracked(this._viewportSize), + viewportSize: untracked(this._effectiveViewportSize), totalSize: untracked(this._engine.totalSize), }; @@ -860,12 +996,16 @@ export class IgxVirtualScrollComponent implements OnDestroy { } private _checkDataRequest(): void { - if (this._hasPendingDataRequest) { + const loaded = untracked(this._loaded); + + // `dataRequest` asks for items to append. A window says how long the collection already + // is, and which part of it is wanted is what `stateChange` reports. + if (this._hasPendingDataRequest || loaded.windowed) { return; } const { endIndex } = untracked(this._visibleRange); - const total = untracked(this._items).length; + const total = loaded.items.length; if (total === 0 || endIndex < total - DATA_REQUEST_THRESHOLD) { return; diff --git a/src/app/combo/combo.sample.ts b/src/app/combo/combo.sample.ts index 194fcd84201..0d6afd3911b 100644 --- a/src/app/combo/combo.sample.ts +++ b/src/app/combo/combo.sample.ts @@ -501,12 +501,12 @@ export class ComboSampleComponent implements OnInit, AfterViewInit { } public onSimpleComboOpened() { - const scroll: number = - this.remoteSimpleCombo.virtualScrollContainer.getScrollForIndex( - this.itemID - 1 - ); - this.remoteSimpleCombo.virtualScrollContainer.scrollPosition = - scroll + this.additionalScroll; + // additionalScroll is one row, set when the selection is the last item. Landing a + // row further down puts that item at the bottom of the viewport. + void this.remoteSimpleCombo.virtualScrollContainer.scrollToIndex( + this.itemID - 1 + (this.additionalScroll ? 1 : 0), + { block: 'start' } + ); this.cdr.detectChanges(); }