Skip to content
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +16 to +18
- `IgxChipComponent`
- Added the `outlined` property to the component. When set to `true`, the Chip will have an outlined style.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -137,15 +130,19 @@ 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();
}

/**
* @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 {
Expand All @@ -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 {
Expand All @@ -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;
}

/**
Expand All @@ -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();
}

Expand Down
92 changes: 70 additions & 22 deletions projects/igniteui-angular/combo/src/combo/combo.common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -768,11 +772,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
public searchInput: ElementRef<HTMLInputElement> = null!;

/** @hidden @internal */
@ViewChild(IgxForOfDirective, { static: true })
public virtualScrollContainer!: IgxForOfDirective<any>;

@ViewChild(IgxForOfDirective, { read: IgxForOfDirective, static: true })
protected virtDir!: IgxForOfDirective<any>;
@ViewChild('virtualScroll', { static: true })
public virtualScrollContainer!: IgxVirtualScrollComponent<any>;

@ViewChild('dropdownItemContainer', { static: true })
protected dropdownContainer: ElementRef = null!;
Expand Down Expand Up @@ -877,7 +878,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
* ```
*/
public get virtualizationState(): IForOfState {
return this.virtDir.state;
return this._virtualizationState;
}
/**
* Sets the current state of the virtualized data.
Expand All @@ -888,7 +889,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
* ```
*/
public set virtualizationState(state: IForOfState) {
this.virtDir.state = state;
this._virtualizationState = { ...state };
void this.virtualScrollContainer?.scrollToIndex(state.startIndex ?? 0);
}

/**
Expand All @@ -911,7 +913,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
* ```
*/
public get totalItemCount(): number {
return this.virtDir.totalItemCount;
return this._totalItemCount;
}
/**
* Sets total count of the virtual data items, when using remote service.
Expand All @@ -922,7 +924,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
* ```
*/
public set totalItemCount(count: number) {
this.virtDir.totalItemCount = count;
this._totalItemCount = count;
}

/** @hidden @internal */
Expand Down Expand Up @@ -968,8 +970,11 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
this._filteringOptions = value;
}

protected containerSize: number | undefined = undefined;
protected itemSize = undefined;
protected itemSize: number | undefined = undefined;

/** The window the list renders, in the shape `virtualizationState` and `dataPreLoad` use. */
private _virtualizationState: IForOfState = { startIndex: 0, chunkSize: 0 };
private _totalItemCount = 0;
protected _data: any[] = [];
protected _value: any[] = [];
protected _displayValue = '';
Expand Down Expand Up @@ -1063,22 +1068,44 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
this.manageRequiredAsterisk();
this.cdr.detectChanges();
}
this.virtDir.chunkPreload.pipe(takeUntil(this.destroy$)).subscribe((e: IForOfState) => {
const eventArgs: IForOfState = Object.assign({}, e, { owner: this });
this.dataPreLoad.emit(eventArgs);
});
this.dropdown?.opening.subscribe((_args: IBaseCancelableBrowserEventArgs) => {
// calculate the container size and item size based on the sizes from the DOM
const dropdownContainerHeight = this.dropdownContainer.nativeElement.getBoundingClientRect().height;
if (dropdownContainerHeight) {
this.containerSize = parseFloat(dropdownContainerHeight);
}
// Take the row height from a real item, for the combos that do not set itemHeight.
if (this.dropdown.children?.first) {
this.itemSize = this.dropdown.children.first.element.nativeElement.getBoundingClientRect().height;
}
});
}

/** @hidden @internal The height the list gets, for the pass that opens the drop-down. */
protected get viewportSize(): number {
return this.itemsMaxHeight || this.estimatedItemSize * this.itemsInContainer;
}

/** @hidden @internal The size rows are assumed to be until they are measured. */
protected get estimatedItemSize(): number {
return this.itemHeight || this.itemSize || DEFAULT_ITEM_SIZE;
}

/** @hidden @internal Where the loaded items sit in the collection they came from. */
protected get virtualStartIndex(): number {
return this._virtualizationState.startIndex ?? 0;
}

/**
* @hidden @internal
* Reports the rendered window as `virtualizationState` and asks for the data behind it.
*/
public handleVirtualStateChange(state: VirtualScrollState): void {
const chunkSize = state.endIndex - state.startIndex + 1;
if (this._virtualizationState.startIndex === state.startIndex &&
this._virtualizationState.chunkSize === chunkSize) {
return;
}

this._virtualizationState = { startIndex: state.startIndex, chunkSize };
this.dataPreLoad.emit({ ...this._virtualizationState, owner: this });
Comment on lines +1105 to +1106
}

/** @hidden @internal */
public ngOnDestroy(): void {
this.destroy$.next();
Expand Down Expand Up @@ -1202,7 +1229,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
this.customValueFlag = false;
this.searchInput?.nativeElement.focus();
this.dropdown.focusedItem = null;
this.virtDir.scrollTo(0);
void this.virtualScrollContainer?.scrollToIndex(0);
}

/** @hidden @internal */
Expand All @@ -1219,14 +1246,35 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh
owner: this,
cancel: false
};
const restore = this.resetVirtualizationState();
this.searchInputUpdate.emit(args);

if (args.cancel) {
this.filterValue = null!;
restore();
} else {
void this.virtualScrollContainer?.scrollToIndex(0);
}
}
this.checkMatch();
}

/**
* @hidden @internal
* Reports the start of the list without moving it. Returns a callback that puts it back.
*/
private resetVirtualizationState(): () => void {
const previous = this._virtualizationState;
if (previous.startIndex === 0) {
return () => { };
}

this._virtualizationState = { startIndex: 0, chunkSize: previous.chunkSize };
return () => {
this._virtualizationState = previous;
};
}

/**
* Event handlers
*
Expand Down
47 changes: 28 additions & 19 deletions projects/igniteui-angular/combo/src/combo/combo.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -70,28 +70,37 @@
}
<ng-container *ngTemplateOutlet="headerTemplate">
</ng-container>
@let itemWindow = data!
| comboFiltering:filterValue:displayKey:filteringOptions:filterFunction:disableFiltering
| comboGrouping:groupKey:valueKey:groupSortingDirection:compareCollator
| comboDataWindow:totalItemCount:virtualStartIndex;
<div #dropdownItemContainer class="igx-combo__content" [style.overflow]="'hidden'"
[style.maxHeight.rem]="itemsMaxHeightInRem" [igxDropDownItemNavigation]="dropdown" (focus)="dropdown.onFocus()"
[tabindex]="dropdown.collapsed ? -1 : 0" [attr.id]="dropdown.id" aria-multiselectable="true"
[attr.aria-activedescendant]="activeDescendant">
<igx-combo-item [itemHeight]="itemHeight" *igxFor="let item of data!
| comboFiltering:filterValue:displayKey:filteringOptions:filterFunction:disableFiltering
| comboGrouping:groupKey:valueKey:groupSortingDirection:compareCollator;
index as rowIndex; initialChunkSize: 10; containerSize: itemsMaxHeight || containerSize; itemSize: itemHeight || itemSize, scrollOrientation: 'vertical';"
[value]="item" [isHeader]="item?.isHeader" [index]="rowIndex" [role]="item?.isHeader? 'group' : 'option'">
@if (item?.isHeader) {
<ng-container
*ngTemplateOutlet="headerItemTemplate ? headerItemTemplate : headerItemBase;
context: {$implicit: item, data: data, valueKey: valueKey, groupKey: groupKey, displayKey: displayKey}">
</ng-container>
}
<!-- if item is 'null' it should be displayed and !!(item?.isHeader) would resolve it to 'false' and not display it -->
@if (!item?.isHeader) {
<ng-container #listItem
*ngTemplateOutlet="template; context: {$implicit: item, data: data, valueKey: valueKey, displayKey: displayKey};">
</ng-container>
}
</igx-combo-item>
<igx-virtual-scroll #virtualScroll
[dataWindow]="itemWindow"
[estimatedItemSize]="estimatedItemSize"
[initialViewportSize]="viewportSize"
(stateChange)="handleVirtualStateChange($event)">
Comment on lines +81 to +85
<ng-template igxVirtualItem let-item let-rowIndex="index">
<igx-combo-item [itemHeight]="itemHeight"
[value]="item" [isHeader]="item?.isHeader" [index]="rowIndex" [role]="item?.isHeader? 'group' : 'option'">
@if (item?.isHeader) {
<ng-container
*ngTemplateOutlet="headerItemTemplate ? headerItemTemplate : headerItemBase;
context: {$implicit: item, data: data, valueKey: valueKey, groupKey: groupKey, displayKey: displayKey}">
</ng-container>
}
<!-- if item is 'null' it should be displayed and !!(item?.isHeader) would resolve it to 'false' and not display it -->
@if (!item?.isHeader) {
<ng-container #listItem
*ngTemplateOutlet="template; context: {$implicit: item, data: data, valueKey: valueKey, displayKey: displayKey};">
</ng-container>
}
</igx-combo-item>
</ng-template>
</igx-virtual-scroll>
</div>

@if (filteredData?.length === 0 || isAddButtonVisible()) {
Expand All @@ -105,7 +114,7 @@
@if (isAddButtonVisible()) {
<igx-combo-add-item [itemHeight]="itemHeight"
[tabindex]="dropdown.collapsed ? -1 : customValueFlag ? 1 : -1" class="igx-combo__add-item" role="button"
[attr.aria-label]="resourceStrings.igx_combo_addCustomValues_placeholder" [index]="virtualScrollContainer.igxForOf!.length">
[attr.aria-label]="resourceStrings.igx_combo_addCustomValues_placeholder" [index]="itemWindow.totalCount">
<ng-container *ngTemplateOutlet="addItemTemplate ? addItemTemplate : addItemDefault">
</ng-container>
</igx-combo-add-item>
Expand Down
Loading