diff --git a/CHANGELOG.md b/CHANGELOG.md index 928db2f64f7..1bd88532d32 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. @@ -25,6 +35,12 @@ All notable changes for each version of this project will be documented in this ### Bug Fixes +- **Accessibility** + - Removed the nested list role from the internal virtual-scroll containers in Combo, Simple Combo and Excel-style filtering, preserving their existing listboxes and options. +- `IgxDropDownComponent` + - Navigation and item lookup now use the same normalized `dataWindow` indices and total count as the projected virtual scroll, including fractional or non-finite metadata and pages extending past the declared total. +- `IgxComboComponent`, `IgxSimpleComboComponent` + - Fixed remote pages changing position before their replacements arrive and redundant requests for an already loaded initial range. Changes to a positive `totalItemCount` refresh the list without rebinding data; a reduced total excludes out-of-range records before filtering and grouping. - `IgxCheckboxComponent` - Fixed the tick-mark icon rendering with the Indigo shape (rounded rect + custom path) inside CSS-scoped subtrees that use a different design system than the application's global theme, e.g. a `material`-themed widget nested inside an `indigo`-themed app. Both tick-mark variants are now always rendered and toggled purely via CSS (`@container style(--ig-theme: indigo)`), removing the dependency on JS-side theme detection that could go stale in nested/multi-theme scenarios (#15021). - **Ripple** diff --git a/projects/igniteui-angular/combo/README.md b/projects/igniteui-angular/combo/README.md index 685a3674814..3aa1057bc24 100644 --- a/projects/igniteui-angular/combo/README.md +++ b/projects/igniteui-angular/combo/README.md @@ -50,6 +50,15 @@ public dataLoading(evt): void { What the combo exposes is a `virtualizationState` property that gives state of the combo - first index and the number of items that needs to be loaded. The service, should inform the combo for the total items that are on the server - using the `totalItemCount` property. +The first rendered range does not emit `dataPreLoad` when the initial page already covers it. +Later range changes still emit the event, so the consumer can cancel a superseded request. +Capture the requested range when starting a fetch and cancel its subscription before replacing +it; assigning `data` does not identify which request produced the page. + +Changing a positive `totalItemCount` refreshes the list without rebinding `data`. If the total +shrinks, loaded records beyond it are excluded while the valid prefix keeps its position. +This limit is applied before filtering and grouping; group headers are not remote records. + ## Features 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..0a6a103d382 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 { @@ -335,6 +339,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh // during filtering & selection for the igx-simple-combo // since the simple combo's input is both a container for the selection and a filter this._data = (val) ? val.filter(x => x !== undefined) : []; + this._loadedStartIndex = this._virtualizationState.startIndex ?? 0; } /** @@ -768,11 +773,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 +879,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 +890,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,18 +914,30 @@ 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. * * ```typescript * // set - * this.combo.totalItemCount(remoteService.count); + * this.combo.totalItemCount = remoteService.count; * ``` */ public set totalItemCount(count: number) { - this.virtDir.totalItemCount = count; + if (this._totalItemCount === count) { + return; + } + this._totalItemCount = count; + this.cdr.markForCheck(); + + // Move an out-of-range viewport without relocating its loaded records. + // The record-window pipe excludes records past the new total. + const lastStart = Math.max(0, count - (this._virtualizationState.chunkSize ?? 0)); + if ((this._virtualizationState.startIndex ?? 0) > lastStart) { + this._virtualizationState = { ...this._virtualizationState, startIndex: lastStart }; + void this.virtualScrollContainer?.scrollToIndex(lastStart); + } } /** @hidden @internal */ @@ -968,8 +983,13 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh this._filteringOptions = value; } - protected containerSize: number | undefined = undefined; - protected itemSize = undefined; + protected itemSize: number | undefined = undefined; + + /** The wanted window, in the shape `virtualizationState` and `dataPreLoad` use. */ + private _virtualizationState: IForOfState = { startIndex: 0, chunkSize: 0 }; + /** Where the records currently bound sit, which a pending request has not moved yet. */ + private _loadedStartIndex = 0; + private _totalItemCount = 0; protected _data: any[] = []; protected _value: any[] = []; protected _displayValue = ''; @@ -1063,22 +1083,55 @@ 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._loadedStartIndex; + } + + /** + * @hidden @internal + * Reports the wanted 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; + } + + const initial = !this._virtualizationState.chunkSize; + const startIndex = state.startIndex; + this._virtualizationState = { startIndex, chunkSize }; + + // The first window a list reports can already be covered by the page it was given, + // and then there is nothing to fetch. Later windows are always reported, so a reply + // to a range the list has left is superseded rather than left in flight. + if (initial && startIndex >= this._loadedStartIndex && + state.endIndex <= this._loadedStartIndex + this._data.length - 1) { + return; + } + + this.dataPreLoad.emit({ ...this._virtualizationState, owner: this }); + } + /** @hidden @internal */ public ngOnDestroy(): void { this.destroy$.next(); @@ -1202,7 +1255,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 +1272,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..0d83960c45f 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.html +++ b/projects/igniteui-angular/combo/src/combo/combo.component.html @@ -70,28 +70,38 @@ } + @let itemWindow = data! + | comboRecordWindow:totalItemCount:virtualStartIndex + | 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 +115,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..d5a6d8161a4 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -1,12 +1,12 @@ import { AsyncPipe } from '@angular/common'; -import { AfterViewInit, ChangeDetectorRef, Component, DebugElement, ElementRef, Injectable, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, provideZonelessChangeDetection } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, DebugElement, ElementRef, Injectable, Injector, OnDestroy, OnInit, ViewChild, inject, ChangeDetectionStrategy, provideZonelessChangeDetection, signal } from '@angular/core'; import { ComponentFixture, fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing'; import { FormsModule, NgForm, NgModel, ReactiveFormsModule, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, Validators } 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,398 @@ 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]]); + + // The first two records of the page it landed on; the earlier two are 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); + }); + + it('should leave the loaded page where it is until a response arrives', async () => { + combo.toggle(); + await settle(); + + expect(combo.virtualScrollContainer.dataWindow().startIndex).toBe(0); + + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + + const request = host.service.requests[host.service.requests.length - 1]; + expect(request.state.startIndex).toBeGreaterThan(300); + + // Nothing has answered it, so the records bound are still the first page. + const window = combo.virtualScrollContainer.dataWindow(); + expect(window.startIndex).toBe(0); + expect(window.items[0].id).toBe(0); + + // The rows the viewport wants have no records behind them, so none render. + const rows = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)) + .nativeElement.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`); + expect(rows.length).toBe(0); + }); + + it('should not ask again for a range the loaded page already covers', async () => { + // A fresh list whose first page is long enough to fill the viewport and + // its over-scan before the list is ever shown. + fixture = TestBed.createComponent(IgxComboDeferredRemoteComponent); + fixture.detectChanges(); + host = fixture.componentInstance; + combo = host.instance; + host.service.complete(host.service.requests[0], 50); + await settle(); + + expect(combo.data.length).toBe(50); + + // Only the page the host asked for itself: the list has nothing left to want. + expect(host.service.requests.length).toBe(1); + + combo.toggle(); + await settle(); + + expect(host.service.requests.length).toBe(1); + }); + + it('should keep the window inside a total that has shrunk', async () => { + combo.toggle(); + await settle(); + + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + host.service.complete(host.service.requests[host.service.requests.length - 1]); + await settle(); + + expect(combo.virtualizationState.startIndex).toBeGreaterThan(300); + + // The collection turns out to be far smaller than it had reported. + combo.totalItemCount = 100; + await settle(); + + expect(combo.virtualizationState.startIndex).toBeLessThan(100); + + const window = combo.virtualScrollContainer.dataWindow(); + expect(window.totalCount).toBe(100); + expect(window.startIndex + window.items.length).toBeLessThanOrEqual(window.totalCount); + + // The scrollbar spans the collection that is left, not the one it replaced. + const scroll = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + const track = scroll.querySelector('.igx-vs__track') as HTMLElement; + expect(Number.parseFloat(track.style.height)).toBe(100 * 40); + + // The records loaded are past the end of what is left, so they are not the + // last page and nothing stands in for them until a valid page arrives. + expect(scroll.querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`).length).toBe(0); + }); + }); + + describe('Binding to remote data without a zone: ', () => { + let host: IgxComboZonelessRemoteComponent; + + const settle = async () => { + await fixture.whenStable(); + await combo.virtualScrollContainer.layoutComplete; + await fixture.whenStable(); + }; + + const rows = () => Array.from(fixture.debugElement + .query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement + .querySelectorAll(`.${CSS_CLASS_DROPDOWNLISTITEM}`)) as HTMLElement[]; + + const rowAt = (row: HTMLElement) => + Number(row.closest('[data-vs-index]')!.getAttribute('data-vs-index')); + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, IgxComboZonelessRemoteComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(IgxComboZonelessRemoteComponent); + host = fixture.componentInstance; + combo = host.instance; + await fixture.whenStable(); + + host.data.set(host.page(0, 50)); + combo.totalItemCount = 1000; + await settle(); + + combo.open(); + await settle(); + }); + + it('should drop a reply to a range the list has already left', async () => { + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + + const away = host.requests[host.requests.length - 1]; + expect(away.startIndex).toBeGreaterThan(300); + + // Coming back asks for what is in view, which supersedes the request that + // is still in flight - the cancellation the consumer already implements. + await combo.virtualScrollContainer.scrollToIndex(0); + await settle(); + + const back = host.requests[host.requests.length - 1]; + expect(back).not.toBe(away); + expect(back.startIndex).toBe(0); + expect(away.response.observed).toBeFalse(); + + // The abandoned reply arrives first and has to change nothing. + host.complete(away); + await settle(); + + expect(combo.data[0].id).toBe(0); + + host.complete(back); + await settle(); + + expect(rows().length).toBeGreaterThan(0); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + + it('should render a grouped page whose rows exceed the remote record count', async () => { + host.groupKey.set('category'); + host.data.set(host.page(0, 10)); + combo.totalItemCount = 10; + await settle(); + + // Every record is loaded; the two headers grouping adds are rows, not + // records, and must not count against the size of the collection. + expect(combo.data.length).toBe(10); + + // Grouping reorders the records, so every one of them is on screen rather + // than each sitting at the index its id would suggest. + const texts = rows().map(row => row.textContent.trim()); + expect(texts.length).toBe(10); + for (let id = 0; id < 10; id++) { + expect(texts).toContain(`Product ${id}`); + } + + const headers = fixture.debugElement + .query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement + .querySelectorAll(`.${CSS_CLASS_HEADERITEM}`); + expect(headers.length).toBeGreaterThan(0); + }); + + for (const total of [100, 2000]) { + it(`should render a remote total of ${total} without rebinding the page`, async () => { + const data = combo.data; + const state = { ...combo.virtualizationState }; + + combo.totalItemCount = total; + await settle(); + + expect(combo.data).toBe(data); + expect(combo.virtualizationState).toEqual(state); + expect(combo.virtualScrollContainer.dataWindow().totalCount).toBe(total); + const track = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)) + .nativeElement.querySelector('.igx-vs__track') as HTMLElement; + expect(Number.parseFloat(track.style.height)).toBe(total * 40); + }); + } + + it('should preserve grouped records at the end of a partially valid remote page', async () => { + host.groupKey.set('category'); + await settle(); + await combo.virtualScrollContainer.scrollToIndex(80); + await settle(); + + const request = host.requests[host.requests.length - 1]; + host.complete(request, 50); + await settle(); + const data = combo.data; + + // Only the total changes: records before 100 remain valid, including the + // last one after grouping has added its headers and reordered the page. + combo.totalItemCount = 100; + await settle(); + + expect(combo.data).toBe(data); + const window = combo.virtualScrollContainer.dataWindow(); + const records = window.items.filter(item => !item.isHeader); + expect(records.map(item => item.id).sort((a, b) => a - b)).toEqual( + host.page(request.startIndex, 100 - request.startIndex).map(item => item.id)); + expect(window.items.filter(item => item.isHeader).length).toBe(2); + + await combo.virtualScrollContainer.scrollToIndex(window.startIndex + window.items.length - 1); + await settle(); + + expect(rows().map(row => row.textContent.trim())).toContain('Product 99'); + for (const row of rows()) { + expect(row.textContent.trim()).toBe(window.items[rowAt(row) - window.startIndex].product); + } + }); + + it('should keep the part of a page that is still inside a shrunken total', async () => { + await combo.virtualScrollContainer.scrollToIndex(80); + await settle(); + + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBeLessThan(80); + + host.complete(request, 50); + await settle(); + + // The collection turns out to hold 100 records. The page reaches past that, + // but the records in view are still inside it. The consumer publishes the + // new total with the page it already has, in one action. + combo.totalItemCount = 100; + host.data.set(host.page(request.startIndex, 50)); + await settle(); + + expect(rows().length).toBeGreaterThan(0); + rows().forEach(row => { + expect(rowAt(row)).toBeLessThan(100); + expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`); + }); + }); + + it('should show accepted filter results from the start of the collection', async () => { + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + host.complete(host.requests[host.requests.length - 1]); + await settle(); + + expect(combo.data[0].id).toBeGreaterThan(300); + + // Searching loads its own page, outside the scrolling request flow. + host.disableFiltering.set(false); + await settle(); + + const search = fixture.debugElement + .query(By.css(CSS_CLASS_SEARCHINPUT)).nativeElement as HTMLInputElement; + search.value = 'Product'; + search.dispatchEvent(new Event('input', { bubbles: true })); + await settle(); + + expect(host.searches.length).toBe(1); + expect(combo.data[0].id).toBe(0); + + // Those records are the head of the collection, not the page left behind. + expect(rows().length).toBeGreaterThan(0); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + + it('should stop showing records the collection no longer has', async () => { + await combo.virtualScrollContainer.scrollToIndex(400); + await settle(); + host.complete(host.requests[host.requests.length - 1]); + await settle(); + + expect(combo.data[0].id).toBeGreaterThan(300); + expect(rows().length).toBeGreaterThan(0); + + // The collection turns out to hold 100 records; the loaded ones are past it. + combo.totalItemCount = 100; + await settle(); + + expect(rows().length).toBe(0); + expect(combo.virtualizationState.startIndex).toBeLessThan(100); + + // The position it settles on is asked for, and what arrives shows there. + const request = host.requests[host.requests.length - 1]; + expect(request.startIndex).toBeLessThan(100); + + host.complete(request); + await settle(); + + expect(rows().length).toBeGreaterThan(0); + rows().forEach(row => expect(row.textContent.trim()).toBe(`Product ${rowAt(row)}`)); + }); + }); + describe('Binding to ngModel tests: ', () => { let component: ComboModelBindingComponent; beforeEach(() => { @@ -1649,8 +2022,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 +2042,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 +2080,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 +2170,58 @@ 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); + // Manual change detection: render what the keyboard event updated. + 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 +2233,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 +2400,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 +2447,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 +2458,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 +2478,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 +2506,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 +2516,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 +3109,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 +3304,39 @@ 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); + + // Every rendered row is one of the matches; how many 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 +3388,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 +4125,37 @@ 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); + expect(focused[0].getAttribute('role')).toBe('option'); + const viewport = focused[0].closest('igx-virtual-scroll'); + expect(viewport.getAttribute('role')).toBe('presentation'); + expect(viewport.closest('[role="listbox"]').id).toBe(combo.dropdown.listId); + expect(dropdownContent.nativeElement.getAttribute('aria-activedescendant')).toBe(focused[0].id); + }); + 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 +4184,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 +4472,134 @@ 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, by default with the page its own state asked for. */ + public complete(request: { state: IForOfState; subject: Subject }, count?: number): void { + const size = count ?? 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: answer the window the event carries, drop the one in flight. */ + 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: ` + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxComboComponent] +}) +export class IgxComboZonelessRemoteComponent implements OnDestroy { + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public instance: IgxComboComponent; + + public data = signal([]); + public requests: { startIndex: number; chunkSize: number; response: Subject }[] = []; + public searches: string[] = []; + public groupKey = signal(undefined); + public disableFiltering = signal(true); + + private pending: Subscription | null = null; + + /** Filtering belongs to the consumer here, so the combo keeps what it is given. */ + public keepAll = (collection: any[]) => collection; + + public page(start: number, count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: start + index, + product: `Product ${start + index}`, + category: `Group ${(start + index) % 2}` + })); + } + + /** The search path loads its own page, outside the scrolling request flow. */ + public search(args: { searchText: string }) { + this.searches.push(args.searchText); + this.pending?.unsubscribe(); + this.pending = null; + this.data.set(this.page(0, 20)); + } + + /** The documented pattern: answer the latest request, drop the one still in flight. */ + public request(state: IForOfState) { + this.pending?.unsubscribe(); + const request = { startIndex: state.startIndex, chunkSize: state.chunkSize, response: new Subject() }; + this.requests.push(request); + this.pending = request.response.subscribe(page => this.data.set(page)); + } + + public complete(request: { startIndex: number; chunkSize: number; response: Subject }, count?: number) { + request.response.next(this.page(request.startIndex, count ?? request.chunkSize)); + request.response.complete(); + } + + public ngOnDestroy() { + this.pending?.unsubscribe(); + } +} + @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..8bf6d401994 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, IgxComboRecordWindowPipe } 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,17 @@ const diffInSets = (set1: Set, set2: Set): any[] => { IgxIconComponent, IgxComboDropDownComponent, IgxDropDownItemNavigationDirective, - IgxForOfDirective, + IgxVirtualScrollComponent, + IgxVirtualItemDirective, IgxComboItemComponent, IgxComboAddItemComponent, IgxButtonDirective, IgxRippleDirective, IgxReadOnlyInputDirective, IgxComboFilteringPipe, - IgxComboGroupingPipe + IgxComboGroupingPipe, + IgxComboDataWindowPipe, + IgxComboRecordWindowPipe ] }) 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..f30928b4c5d 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.pipes.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.pipes.ts @@ -1,8 +1,50 @@ 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 + * Keeps the loaded records inside the remote total without changing their indices. + * Runs before grouping, so headers do not count against the number of remote records. + */ +@Pipe({ + name: 'comboRecordWindow' +}) +export class IgxComboRecordWindowPipe implements PipeTransform { + public transform(collection: any[], totalItemCount: number, startIndex: number): any[] { + const remaining = Math.max(0, totalItemCount - startIndex); + return totalItemCount > 0 && collection.length > remaining + ? collection.slice(0, remaining) + : collection; + } +} + +/** + * @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 { + if (!(totalItemCount > 0)) { + return { items: collection, startIndex: 0, totalCount: collection.length }; + } + + // An empty page has no position of its own, and anchoring one would stretch the + // collection to reach it. + return { items: collection, startIndex: collection.length ? startIndex : 0, totalCount: totalItemCount }; + } +} + + + @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..e20bc65e1ae 100644 --- a/projects/igniteui-angular/combo/src/combo/themes/_base.scss +++ b/projects/igniteui-angular/combo/src/combo/themes/_base.scss @@ -58,14 +58,15 @@ $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 it is allowed. + 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..30a5c86ac98 100644 --- a/projects/igniteui-angular/drop-down/README.md +++ b/projects/igniteui-angular/drop-down/README.md @@ -71,7 +71,41 @@ 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. + +Use `role="presentation"` on this scrolling container so the options belong to the drop-down's listbox without an intervening list role. + +For paged `dataWindow` bindings, navigation and item lookup use the same normalization as the virtual scroll: `startIndex` and `totalCount` are truncated to integers and clamped to zero or above; non-finite values become zero. The effective count is at least the normalized start index plus the page length, even when the declared total is smaller. + +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 +134,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 +169,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 +194,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..f796c4c6690 --- /dev/null +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-virtualization.ts @@ -0,0 +1,244 @@ +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'; + +/** Match the index/count normalization used by `IgxVirtualScrollComponent.dataWindow`. */ +function toCount(value: number): number { + const count = Math.trunc(Number(value)); + return Number.isFinite(count) ? Math.max(0, count) : 0; +} + +/** + * @hidden @internal + * + * What differs between a projected `*igxFor` and a projected `igx-virtual-scroll`, so the + * drop-down keeps one path for navigation, scrolling and active-descendant tracking. + * Items are addressed by their index in the whole collection, not in the loaded subset. + */ +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; + + /** Whether a record has been loaded for an index, rendered or not. */ + isIndexLoaded(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(); + + constructor( + private _scroll: IgxVirtualScrollComponent, + private _ref: ElementRef + ) { } + + public get length(): number { + const window = this._scroll.dataWindow(); + return window + ? Math.max(toCount(window.totalCount), this.startIndex + (window.items?.length ?? 0)) + : (this._scroll.data() ?? []).length; + } + + public get startIndex(): number { + return toCount(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 - this.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 + this.startIndex; + } + + /** `stateChange` reports the range wanted, which reaches past the rows that arrived. */ + public isIndexRendered(index: number): boolean { + return !!this._ref.nativeElement.querySelector(`[data-vs-index="${index}"]`); + } + + public isIndexLoaded(index: number): boolean { + const window = this._scroll.dataWindow(); + const count = window ? (window.items?.length ?? 0) : (this._scroll.data() ?? []).length; + return Number.isInteger(index) + && index >= this.startIndex + && index < this.startIndex + count; + } + + /** `'nearest'` leaves the offset alone when the item is already fully in view. */ + 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 isIndexLoaded(index: number): boolean { + return Number.isInteger(index) && index >= 0 && index < this._items.length; + } + + 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..abe6a8ee0d6 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, VirtualDataWindow } from 'igniteui-angular/virtual-scroll'; +import { createDropDownVirtualization, IgxDropDownVirtualization } 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,11 @@ 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], + isIndexLoaded: (index: number) => Number.isInteger(index) && index >= 0 && index < data.length, + disconnect: () => { } + }; const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null } }); beforeEach(() => { @@ -61,7 +67,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 +80,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 +103,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 +132,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 +141,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 +165,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 +1035,454 @@ 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. + 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('Windowed virtual scroll', () => { + let host: WindowedVirtualScrollDropDownComponent; + + const settle = async () => { + await fixture.whenStable(); + await host.scroll.layoutComplete; + await fixture.whenStable(); + }; + + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, WindowedVirtualScrollDropDownComponent], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + + fixture = TestBed.createComponent(WindowedVirtualScrollDropDownComponent); + host = fixture.componentInstance; + dropdown = host.dropdown; + await settle(); + }); + + it('should select a loaded global index before its row is rendered', async () => { + const page = { ...host.pageAt(400), totalCount: 1000 }; + host.window.set(page); + dropdown.open(); + await settle(); + await host.scroll.scrollToIndex(400); + await settle(); + + const viewport = fixture.nativeElement.querySelector('igx-virtual-scroll') as HTMLElement; + expect(viewport.querySelector('[data-vs-index="419"]')).toBeNull(); + const emit = spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + + dropdown.setSelectedItem(419); + await settle(); + + expect(dropdown.selectedItem?.index).toBe(419); + expect(dropdown.selectedItem?.value).toBe(page.items[19]); + expect(emit).toHaveBeenCalledOnceWith({ + oldSelection: null, + newSelection: { value: page.items[19], index: 419 } as IgxDropDownItemBaseDirective, + cancel: false, + owner: dropdown + }); + + await host.scroll.scrollToIndex(419); + await settle(); + const selected = viewport.querySelector(`.${CSS_CLASS_SELECTED}`); + expect(selected?.textContent).toContain('Item 419'); + expect(selected?.getAttribute('aria-selected')).toBe('true'); + expect(selected?.closest('[data-vs-index]').getAttribute('data-vs-index')).toBe('419'); + }); + + it('should allow cancelling selection of a loaded global index', async () => { + const page = { ...host.pageAt(400), totalCount: 1000 }; + host.window.set(page); + await settle(); + dropdown.selectItem({ value: page.items[0], index: 400 } as IgxDropDownItemBaseDirective); + const previous = dropdown.selectedItem; + const changing = jasmine.createSpy('selectionChanging').and.callFake((args: ISelectionEventArgs) => { + args.cancel = true; + }); + dropdown.selectionChanging.subscribe(changing); + + dropdown.setSelectedItem(419); + await settle(); + + expect(changing).toHaveBeenCalledOnceWith({ + oldSelection: previous, + newSelection: { value: page.items[19], index: 419 } as IgxDropDownItemBaseDirective, + cancel: true, + owner: dropdown + }); + expect(dropdown.selectedItem).toBe(previous); + }); + + it('should ignore invalid or unloaded selection indices without emitting', async () => { + const page = { ...host.pageAt(400), totalCount: 1000 }; + host.window.set(page); + dropdown.open(); + await settle(); + await host.scroll.scrollToIndex(400); + await settle(); + dropdown.selectItem({ value: page.items[0], index: 400 } as IgxDropDownItemBaseDirective); + const previous = dropdown.selectedItem; + const emit = spyOn(dropdown.selectionChanging, 'emit').and.callThrough(); + + for (const index of [-1, 0, 1.5, NaN, Infinity, 420, 1000]) { + expect(() => dropdown.setSelectedItem(index)).not.toThrow(); + expect(dropdown.selectedItem).withContext(`index ${index}`).toBe(previous); + } + await settle(); + expect(emit).not.toHaveBeenCalled(); + }); + + it('should allow falsy values in a loaded page', async () => { + const items = [0, false, '', null]; + host.window.set({ items, startIndex: 0, totalCount: items.length }); + await settle(); + + for (let index = 0; index < items.length; index++) { + dropdown.setSelectedItem(index); + await settle(); + expect(dropdown.selectedItem?.index).toBe(index); + expect(dropdown.selectedItem?.value).toBe(items[index]); + } + }); + + [ + { start: 40, total: 100, expectedStart: 40, expectedTotal: 100 }, + { start: 40.7, total: 100.7, expectedStart: 40, expectedTotal: 100 }, + { start: -4.7, total: 100, expectedStart: 0, expectedTotal: 100 }, + { start: NaN, total: 100, expectedStart: 0, expectedTotal: 100 }, + { start: Infinity, total: 100, expectedStart: 0, expectedTotal: 100 }, + { start: -Infinity, total: 100, expectedStart: 0, expectedTotal: 100 }, + { start: 40, total: 10, expectedStart: 40, expectedTotal: 60 }, + { start: 40, total: -100, expectedStart: 40, expectedTotal: 60 }, + { start: 40, total: NaN, expectedStart: 40, expectedTotal: 60 }, + { start: 40, total: Infinity, expectedStart: 40, expectedTotal: 60 } + ].forEach(({ start, total, expectedStart, expectedTotal }) => { + it(`should use the rendered indices for a page at ${start} with total ${total}`, async () => { + const items = host.pageAt(expectedStart).items; + host.window.set({ items, startIndex: start, totalCount: total }); + dropdown.open(); + await settle(); + await host.scroll.scrollToIndex(expectedStart); + await settle(); + + const adapter = (dropdown as any).virtualization as IgxDropDownVirtualization; + expect(adapter.startIndex).toBe(expectedStart); + expect(adapter.length).toBe(expectedTotal); + expect(adapter.itemAt(expectedStart)).toBe(items[0]); + expect(adapter.itemAt(expectedStart - 1)).toBeUndefined(); + expect(adapter.itemAt(expectedStart + items.length)).toBeUndefined(); + expect(adapter.findIndex(item => item === items[0])).toBe(expectedStart); + expect(adapter.findIndex(item => item === items.at(-1))).toBe(expectedStart + items.length - 1); + expect(adapter.findIndex(() => false)).toBe(-1); + + const viewport = fixture.nativeElement.querySelector('igx-virtual-scroll') as HTMLElement; + expect(viewport.querySelector('.igx-vs__track').style.height).toBe(`${expectedTotal * 28}px`); + expect(viewport.querySelector(`[data-vs-index="${expectedStart}"]`)?.textContent).toContain(items[0]); + + dropdown.navigateItem(expectedStart); + await settle(); + + const focused = viewport.querySelector(`.${CSS_CLASS_FOCUSED}`); + expect(dropdown.focusedItem?.value).toBe(items[0]); + expect(dropdown.focusedItem?.index).toBe(expectedStart); + expect(focused?.textContent).toContain(items[0]); + expect(focused?.closest('[data-vs-index]').getAttribute('data-vs-index')).toBe(`${expectedStart}`); + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + expect(input.getAttribute('aria-activedescendant')).toBe(focused?.id); + }); + }); + + it('should point aria-activedescendant at a row the arriving page renders', async () => { + dropdown.open(); + await settle(); + + // Lands in a hole: rows 0-19 are all there is, so nothing is named. + dropdown.navigateItem(50); + await settle(); + expect(dropdown.activeDescendant).toBeNull(); + + // The page arrives without moving anything: rows measure at the estimate, so + // range, viewport and total size all hold what was already reported. + host.window.set(host.pageAt(40, 30)); + await settle(); + + expect(fixture.nativeElement.querySelector('[data-vs-index="50"]')).toBeTruthy(); + + // The query resolves the row, so this is the option itself, not just a name. + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.value).toBe('Item 50'); + expect(dropdown.activeDescendant).toBe(dropdown.focusedItem.element.nativeElement.id); + + const focused = fixture.nativeElement.querySelector(`.${CSS_CLASS_FOCUSED}`) as HTMLElement; + expect(focused).toBe(dropdown.focusedItem.element.nativeElement); + expect(focused.textContent).toContain('Item 50'); + }); + + it('should keep navigating with the keyboard once the page has arrived', async () => { + dropdown.open(); + await settle(); + + dropdown.navigateItem(50); + await settle(); + host.window.set(host.pageAt(40, 30)); + await settle(); + + expect(dropdown.focusedItem.value).toBe('Item 50'); + + const input = fixture.nativeElement.querySelector('input') as HTMLInputElement; + input.focus(); + await settle(); + + expect(document.activeElement).toBe(input); + + // The row after it is loaded too, so the next keystroke moves onto a real item. + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await settle(); + + expect(dropdown.focusedItem.value).toBe('Item 51'); + const focused = fixture.nativeElement.querySelector(`.${CSS_CLASS_FOCUSED}`) as HTMLElement; + expect(focused.textContent).toContain('Item 51'); + expect(dropdown.activeDescendant).toBe(focused.id); + expect(input.getAttribute('aria-activedescendant')).toBe(focused.id); + }); + + it('should follow a replaced projected virtual scroll', async () => { + dropdown.open(); + await settle(); + + // The projection is swapped for a second instance; the first one is gone. + const first = host.scroll; + + // Lifecycle only: the watcher is rebuilt with the adapter. This does not show + // that the rebuilt one is what refreshes any later page. + const previousWatcher = (dropdown as any)._renderedItems; + expect(previousWatcher).toBeTruthy(); + const destroySpy = spyOn(previousWatcher, 'destroy').and.callThrough(); + + host.useSecond.set(true); + await fixture.whenStable(); + host.window.set(host.pageAt(0)); + await settle(); + + expect(host.scroll).not.toBe(first); + expect(destroySpy).toHaveBeenCalledTimes(1); + expect((dropdown as any)._renderedItems).toBeTruthy(); + expect((dropdown as any)._renderedItems).not.toBe(previousWatcher); + + dropdown.navigateItem(50); + await settle(); + expect(dropdown.activeDescendant).toBeNull(); + + host.window.set(host.pageAt(40, 30)); + await settle(); + + expect(dropdown.focusedItem?.value).toBe('Item 50'); + expect(dropdown.activeDescendant).toBe(dropdown.focusedItem.element.nativeElement.id); + }); + + it('should name the option itself when the template wraps it', async () => { + // The option can sit inside a container with an id of its own. + host.wrapped.set(true); + await settle(); + + dropdown.open(); + await settle(); + + dropdown.navigateItem(50); + await settle(); + expect(dropdown.activeDescendant).toBeNull(); + + host.window.set(host.pageAt(40, 30)); + await settle(); + + const option = fixture.nativeElement + .querySelector('[data-vs-index="50"] igx-drop-down-item') as HTMLElement; + expect(option).toBeTruthy(); + expect(dropdown.activeDescendant).toBe(option.id); + + // The row the listbox names is the row the focus is drawn on. + const focused = fixture.nativeElement.querySelector(`.${CSS_CLASS_FOCUSED}`) as HTMLElement; + expect(focused).toBe(option); + + const input = fixture.nativeElement.querySelector('input') as HTMLElement; + expect(input.getAttribute('aria-activedescendant')).toBe(option.id); + }); + + it('should stop naming an option once the collection is empty', async () => { + dropdown.open(); + await settle(); + + dropdown.navigateItem(0); + await settle(); + expect(dropdown.activeDescendant).toBeTruthy(); + + // Everything goes away underneath a drop-down that still holds a focused row. + host.window.set({ items: [], startIndex: 0, totalCount: 0 }); + await settle(); + + expect(fixture.nativeElement.querySelector('igx-drop-down-item')).toBeNull(); + expect(dropdown.activeDescendant).toBeNull(); + + const input = fixture.nativeElement.querySelector('input') as HTMLElement; + expect(input.getAttribute('aria-activedescendant')).toBeFalsy(); + }); + }); + describe('Zoneless virtualization tests', () => { let scroll: IgxForOfDirective; beforeEach(async () => { @@ -1456,6 +1910,99 @@ 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: ` + + @if (useSecond()) { + + + {{item}} + + + } @else { + + + @if (wrapped()) { +
+ {{item}} +
+ } @else { + {{item}} + } +
+
+ } +
`, + imports: [ + IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, + IgxVirtualItemDirective, IgxVirtualScrollComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class WindowedVirtualScrollDropDownComponent { + @ViewChild(IgxDropDownComponent, { static: true }) + public dropdown: IgxDropDownComponent; + + @ViewChild(IgxVirtualScrollComponent) + public scroll: IgxVirtualScrollComponent; + + /** Whether each option is rendered inside a container of the consumer's own. */ + public wrapped = signal(false); + /** Swaps in a second `igx-virtual-scroll`, replacing the projected instance. */ + public useSecond = signal(false); + public window = signal>(this.pageAt(0)); + + /** A loaded page the way a remote response carries one, over 100 records. */ + public pageAt(startIndex: number, count = 20): VirtualDataWindow { + return { + items: Array.from({ length: count }, (_, i) => `Item ${startIndex + i}`), + startIndex, + totalCount: 100, + }; + } +} + @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..2fa2367a81b 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 @@ -1,14 +1,17 @@ import { Component, + afterNextRender, ContentChildren, + effect, + EffectRef, ElementRef, forwardRef, + Injector, QueryList, OnChanges, Input, OnDestroy, ViewChild, - ContentChild, AfterViewInit, Output, EventEmitter, @@ -26,10 +29,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'; @@ -61,8 +66,12 @@ import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; }) export class IgxDropDownComponent extends IgxDropDownBaseDirective implements IDropDownBase, OnChanges, AfterViewInit, OnDestroy { protected selection = inject(IgxSelectionAPIService); + private _reconcileInjector = inject(Injector); protected _activeDescendantId: string | null = null; + /** Watches the data the projected virtual scroll renders from. */ + private _renderedItems: EffectRef | null = null; + /** * @hidden * @internal @@ -150,8 +159,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 +181,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 +197,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 +207,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 +261,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 * @@ -307,19 +330,24 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @param index of the item to select; If the drop down uses *igxFor, pass the index in data */ public setSelectedItem(index: number) { - if (index < 0 || index >= this.items.length) { + if (this.virtualization) { + // A virtualized index addresses the whole collection. Any record loaded for it + // can be selected, which is more than the rows that happen to be rendered. + if (!this.virtualization.isIndexLoaded(index)) { + return; + } + + this.selectItem({ + value: this.virtualization.itemAt(index), + index + } as IgxDropDownItemBaseDirective); return; } - let newSelection: IgxDropDownItemBaseDirective; - if (this.virtDir) { - newSelection = { - value: this.virtDir.igxForOf![index], - index - } as IgxDropDownItemBaseDirective; - } else { - newSelection = this.items[index]; + + if (index < 0 || index >= this.items.length) { + return; } - this.selectItem(newSelection); + this.selectItem(this.items[index]); } /** @@ -329,27 +357,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 +387,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 +408,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 +417,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 +440,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 +457,8 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID * @hidden @internal */ public ngOnDestroy() { + this._renderedItems?.destroy(); + this.virtualization?.disconnect(); this.destroy$.next(true); this.destroy$.complete(); this.selection.delete(this.id); @@ -472,16 +494,91 @@ 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()); + + // A sliding window reuses its items, so only the adapter reports it; a page + // arriving or the list emptying builds or drops them, which reaches this query. + // The query settles inside the pass that rendered them, and the element it names + // was already checked in it, so the write waits for the render to finish. + this.children.changes + .pipe(takeUntil(this.destroy$)) + .subscribe(() => afterNextRender( + () => this.refreshActiveDescendant(), + { injector: this._reconcileInjector } + )); + } + + /** + * 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()); + this.watchRenderedItems(scroll); + } + + /** + * Keeps the item query in step with the rows a projected `igx-virtual-scroll` renders. + * The query collects them only while the view that declares them is checked, and a + * page arriving dirties the scroll rather than that view. Asking for the check is all + * it takes; `children.changes` reports the rest. + */ + private watchRenderedItems(scroll: IgxVirtualScrollComponent | undefined): void { + this._renderedItems?.destroy(); + this._renderedItems = null; + + if (!scroll) { + return; + } + + this._renderedItems = effect(() => { + scroll.data(); + scroll.dataWindow(); + this.cdr.markForCheck(); + }, { injector: this._reconcileInjector }); + } + + /** Points `aria-activedescendant` at the item the focused index renders as, if any. */ + protected refreshActiveDescendant(): void { + const index = this._focusedItem?.index; + const item = index !== undefined && index !== -1 + ? this.children?.find(e => e.index === index) + : null; + this._activeDescendantId = item?.id ?? null; + this.cdr.markForCheck(); } /** Keydown Handler */ @@ -496,7 +593,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 +604,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 +615,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 +626,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 +653,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 +668,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 +710,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 +747,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..839d67b2f8b 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,34 @@ (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..1cbef15a118 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,8 @@ 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(); + // Only flushes the bindings the surrounding menu changed; the list measures itself. + if (this.virtualScroll && !(this.cdr as any).destroyed) { this.cdr.detectChanges(); } } @@ -355,41 +352,33 @@ 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. + * Scrolling recycles rows, so it can take the focused row's element away. */ - 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 +394,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 +442,7 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { if (!this.esf.listData || !this.esf.listData.length) { this.displayedListData = []; + this.reconcileEmptyList(); return; } @@ -534,6 +520,8 @@ export class IgxExcelStyleSearchComponent implements AfterViewInit, OnDestroy { } } + this.reconcileEmptyList(); + if (this.displayedListData.length > 2) { this.matchesCount = this.displayedListData.length - 2; } else { @@ -717,20 +705,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 +841,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 +879,75 @@ 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 it now if it is rendered, nothing while the scroll is bringing it in. + 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 window reaches above it by the over-scan + * buffer, so its start index would focus a row that is off screen. + */ + 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: a cached range would need every render, + // and an unchanged window is not reported twice. + 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..69119cfa6a3 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,12 @@ $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 scrolling viewport takes the height the list has left, not 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..3a19301e2d1 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,34 @@ 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; + + // Few enough values that the list renders all of them, so the extent is their + // measured height. A virtualized collection keeps the estimate for the rest. + 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 +4153,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 +4452,138 @@ 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 real events detect their own changes, the way an application does. + 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 a different height, so the first round resizes the viewport. + // The second brings rows back to a window already reported. + 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 named row 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 +4618,113 @@ 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); + + // Half a row down, so the first row on screen is cut by the viewport edge. + 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 window reaches above the viewport, so this must be the first row 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(); + + // Cut by the top edge rather than below it: a partial row still counts as shown. + expect(focusedBox.bottom).toBeGreaterThan(viewportTop); + expect(focusedBox.top).toBeLessThan(viewportTop); + + // Nothing above it reaches the viewport, so it is the first that does. + 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 +4749,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 +4998,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 +7577,91 @@ 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); + expect(focused.getAttribute('role')).toBe('option'); + const viewport = focused.closest('igx-virtual-scroll'); + expect(viewport.getAttribute('role')).toBe('presentation'); + expect(viewport.closest('[role="listbox"]')).toBe(list); + }); +}); + 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/README.md b/projects/igniteui-angular/simple-combo/README.md index 3de5efbd457..6e33e486379 100644 --- a/projects/igniteui-angular/simple-combo/README.md +++ b/projects/igniteui-angular/simple-combo/README.md @@ -50,6 +50,9 @@ public dataLoading(evt): void { What the combo exposes is a `virtualizationState` property that gives state of the combo - first index and the number of items that needs to be loaded. The service, should inform the combo for the total items that are on the server - using the `totalItemCount` property. +Remote paging follows the same [request and total-count behavior as Combo](../combo/README.md#usage), +including cancelling superseded requests and refreshing the list when only `totalItemCount` changes. + ## Features ### Selection 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..f31bcdfe5d3 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,12 @@ +@let itemWindow = data! + | comboRecordWindow:totalItemCount:virtualStartIndex + | 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 +112,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..a06b7b7b0d2 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,141 @@ 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 indices are not the bound array's. + 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); + const focused = fixture.nativeElement.querySelector('.igx-drop-down__item--focused') as HTMLElement; + expect(focused.textContent).toContain('Product 3'); + expect(focused.getAttribute('role')).toBe('option'); + const viewport = focused.closest('igx-virtual-scroll'); + expect(viewport.getAttribute('role')).toBe('presentation'); + const listbox = viewport.closest('[role="listbox"]'); + expect(listbox).toBeTruthy(); + expect(listbox.id).toBe(combo.dropdown.listId); + expect(viewport.closest('.igx-combo__content').getAttribute('aria-activedescendant')).toBe(focused.id); + }); + + 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: per-record resolution would rescan the collection each time. + 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 +3289,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..31869cf08e3 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, IgxComboRecordWindowPipe } 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, IgxComboRecordWindowPipe] }) 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..ad312eaecfd 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: once the host has been laid out its own size takes over, zero included, and the input is read again only when `orientation` begins an axis that has no measurement of its own. 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..c1878d90e37 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,22 @@ export interface VirtualScrollState extends VisibleRange { totalSize: number; } +/** + * A loaded page of a larger collection, for data that arrives a page at a time. + * + * The list is sized by `totalCount` while only `items` are in memory. Indices are indices + * in the whole collection: the item at `index` is `items[index - startIndex]`, and indices + * the page does not cover render nothing. + */ +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..ad62da4c00b 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,379 @@ 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. + 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 gives 9 rows, the 600px host 15, so the count says which is in use. + 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, so zero is its real size and the hint has no say. + 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. + 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 earlier request must not block 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 goes in: the page for where it landed arrives last. + 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)); + + // Unmeasured rows would leave the scrollbar on the estimate. + 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 not report a range again when the page it asked for arrives', async () => { + // The page answering the report fills the hole without moving anything: rows + // measure at the estimate, so range, viewport and total size are unchanged. + await bindWindow(windowHost.pageAt(0)); + await windowScroll.scrollToIndex(400); + await settle(windowFixture, windowScroll); + + const wanted = windowHost.states.at(-1)!; + const count = wanted.endIndex - wanted.startIndex + 1; + windowHost.states.length = 0; + + await bindWindow(windowHost.pageAt(wanted.startIndex, count)); + + // A consumer fetching per report would ask for the page it was just given. + expect(vsIndices(windowFixture)).toContain(wanted.startIndex); + expect(windowHost.states.filter(state => + state.startIndex === wanted.startIndex && state.endIndex === wanted.endIndex)).toEqual([]); + }); + + it('should report a moved range whose loaded part has not changed', async () => { + // Two loaded rows under a viewport reaching past both: moving one row down + // changes the range asked for, not the part that has data behind it. + 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 render either way, so only this report says it moved. + 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, so the first rendered index is where it begins. + 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 count normalizing 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..0c9c677b149 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,40 @@ 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: once the host has been laid out its own size takes over, + * zero included. Changing `orientation` begins an axis with no measurement of its own, so + * the hint applies again there. 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 +307,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 +374,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { return this._resolvedTemplate() ? this._engine.getVisibleRange( this._scrollPosition, - this._viewportSize(), + this._effectiveViewportSize(), this._normalizedOverScan(), ) : EMPTY_RANGE; @@ -304,14 +385,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 +420,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 +448,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 +483,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 +502,10 @@ export class IgxVirtualScrollComponent implements OnDestroy { // the window or the engine's sizes change. afterRenderEffect({ read: () => { + // Separate dependencies once a window is bound: the viewport can move while the + // part of it the page covers stays identical. 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,14 @@ 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 is hidden or detached, not sized: its last measurement is kept so + // it renders in the pass that reveals it. 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 +848,7 @@ export class IgxVirtualScrollComponent implements OnDestroy { const next = this._engine.getVisibleRange( this._scrollPosition, - untracked(this._viewportSize), + untracked(this._effectiveViewportSize), untracked(this._normalizedOverScan), ); @@ -819,11 +934,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 +947,23 @@ export class IgxVirtualScrollComponent implements OnDestroy { return shared; } + /** + * How many leading items keep their measured size across a change. A page keeps all of + * them - its indices still mean the same records. 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 +978,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 +991,15 @@ export class IgxVirtualScrollComponent implements OnDestroy { } private _checkDataRequest(): void { - if (this._hasPendingDataRequest) { + const loaded = untracked(this._loaded); + + // `dataRequest` asks for items to append, which a sized collection does not need. + 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(); }