From c1094499cb0cef0f81553b0c45f61969c1577fbf Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Thu, 2 Jul 2026 10:09:40 +0300 Subject: [PATCH 1/3] test(dropdown): Add zoneless test for virtual dropdown --- .../src/drop-down/drop-down.component.spec.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) 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 d68ebd11197..819db5507f5 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 } from '@angular/core'; +import { Component, ViewChild, OnInit, ElementRef, ViewChildren, QueryList, ChangeDetectorRef, DOCUMENT, ChangeDetectionStrategy, provideZonelessChangeDetection } 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'; @@ -1031,6 +1031,37 @@ describe('IgxDropDown ', () => { const scrollTop = virtualScroll.getScroll().scrollTop; expect(expectedScroll - acceptableDelta < scrollTop && expectedScroll + acceptableDelta > scrollTop).toBe(true); }); + describe('Zoneless', () => { + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + VirtualizedDropDownComponent + ], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + }); + beforeEach(() => { + fixture = TestBed.createComponent(VirtualizedDropDownComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + scroll = fixture.componentInstance.virtualScroll; + }); + it('should not throw when scrolling after selecting an item', async () => { + const preSelected = { value: fixture.componentInstance.items[0], index: 0 } as IgxDropDownItemBaseDirective; + dropdown.selectItem(preSelected); + + dropdown.toggle(); + await wait(50); + fixture.detectChanges(); + + scroll.getScroll().scrollTop = scroll.getScroll().scrollHeight; + await wait(50); + + expect(() => fixture.detectChanges()).not.toThrow(); + }); + }); }); describe('Rendering', () => { describe('Accessibility', () => { From bba2ebef998e843124d4ba81767707806f3bafcf Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Thu, 2 Jul 2026 10:38:51 +0300 Subject: [PATCH 2/3] fix(dropdown): manually handle activeDescendant in virtual mode dropdown --- .../src/combo/combo-dropdown.component.ts | 2 -- .../src/drop-down/drop-down.component.ts | 20 ++++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) 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 e1b3d0ef35c..96a31533d58 100644 --- a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts @@ -18,8 +18,6 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I public combo = inject(IGX_COMBO_COMPONENT); protected comboAPI = inject(IgxComboAPIService); - private _activeDescendantId: string | null = null; - /** @hidden @internal */ @Input({ transform: booleanAttribute }) public singleMode = false; 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 871345cce1a..111ad7c343d 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 @@ -28,7 +28,7 @@ import { IgxSelectionAPIService } from 'igniteui-angular/core'; import { Subject } from 'rxjs'; import { IgxDropDownItemBaseDirective } from './drop-down-item.base'; import { IgxForOfToken } from 'igniteui-angular/directives'; -import { take } from 'rxjs/operators'; +import { take, takeUntil } from 'rxjs/operators'; import { OverlaySettings } from 'igniteui-angular/core'; import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; @@ -58,6 +58,7 @@ import { ConnectedPositioningStrategy } from 'igniteui-angular/core'; }) export class IgxDropDownComponent extends IgxDropDownBaseDirective implements IDropDownBase, OnChanges, AfterViewInit, OnDestroy { protected selection = inject(IgxSelectionAPIService); + protected _activeDescendantId: string | null = null; /** * @hidden @@ -171,6 +172,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID if (!value) { this.selection.clear(`${this.id}-active`); this._focusedItem = null; + this._activeDescendantId = null; return; } this._focusedItem = value; @@ -183,6 +185,13 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID this.selection.set(`${this.id}-active`, new Set([this._focusedItem])); } + public override get activeDescendant(): string | null { + if (this.virtDir) { + return this._activeDescendantId; + } + return super.activeDescendant; + } + public override get id(): string { return this._id; } @@ -335,6 +344,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID this.skipHeader(direction); }); } else { + this._activeDescendantId = this.children.find(e => e.index === index)?.id ?? null; this.skipHeader(direction); } } else { @@ -461,6 +471,13 @@ 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(); + }); } } @@ -610,6 +627,7 @@ export class IgxDropDownComponent extends IgxDropDownBaseDirective implements ID protected updateItemFocus() { if (this.selectedItem) { this.focusedItem = this.selectedItem; + this._activeDescendantId = this.focusedItem?.id ?? null; this.focusItem(true); } else if (this.allowItemsFocus) { this.navigateFirst(); From 8a9b3822ab3b0cbd1d64efe59d48d7032d0b61af Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Thu, 2 Jul 2026 16:14:06 +0300 Subject: [PATCH 3/3] chore(*): move zoneless test suite --- .../src/drop-down/drop-down.component.spec.ts | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) 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 819db5507f5..0846fad0718 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 @@ -46,7 +46,7 @@ describe('IgxDropDown ', () => { const mockCdr = jasmine.createSpyObj('ChangeDetectorRef', ['markForCheck', 'detectChanges']); mockSelection.get.and.returnValue(new Set([])); const mockForOf = jasmine.createSpyObj('IgxForOfDirective', ['totalItemCount']); - const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null }}); + const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null } }); beforeEach(() => { TestBed.configureTestingModule({ @@ -855,7 +855,7 @@ describe('IgxDropDown ', () => { const itemToClick = fixture.debugElement.queryAll(By.css(`.${CSS_CLASS_ITEM}`))[0]; - const event = new Event('mousedown', { }); + const event = new Event('mousedown', {}); spyOn(event, 'preventDefault'); itemToClick.triggerEventHandler('mousedown', event); @@ -1031,36 +1031,35 @@ describe('IgxDropDown ', () => { const scrollTop = virtualScroll.getScroll().scrollTop; expect(expectedScroll - acceptableDelta < scrollTop && expectedScroll + acceptableDelta > scrollTop).toBe(true); }); - describe('Zoneless', () => { - beforeEach(async () => { - TestBed.resetTestingModule(); - await TestBed.configureTestingModule({ - imports: [ - NoopAnimationsModule, - VirtualizedDropDownComponent - ], - providers: [provideZonelessChangeDetection()] - }).compileComponents(); - }); - beforeEach(() => { - fixture = TestBed.createComponent(VirtualizedDropDownComponent); - fixture.detectChanges(); - dropdown = fixture.componentInstance.dropdown; - scroll = fixture.componentInstance.virtualScroll; - }); - it('should not throw when scrolling after selecting an item', async () => { - const preSelected = { value: fixture.componentInstance.items[0], index: 0 } as IgxDropDownItemBaseDirective; - dropdown.selectItem(preSelected); + }); + describe('Zoneless virtualization tests', () => { + let scroll: IgxForOfDirective; + beforeEach(async () => { + TestBed.resetTestingModule(); + await TestBed.configureTestingModule({ + imports: [ + NoopAnimationsModule, + VirtualizedDropDownComponent + ], + providers: [provideZonelessChangeDetection()] + }).compileComponents(); + fixture = TestBed.createComponent(VirtualizedDropDownComponent); + fixture.detectChanges(); + dropdown = fixture.componentInstance.dropdown; + scroll = fixture.componentInstance.virtualScroll; + }); + it('should not throw when scrolling after selecting an item', async () => { + const preSelected = { value: fixture.componentInstance.items[0], index: 0 } as IgxDropDownItemBaseDirective; + dropdown.selectItem(preSelected); - dropdown.toggle(); - await wait(50); - fixture.detectChanges(); + dropdown.toggle(); + await wait(50); + fixture.detectChanges(); - scroll.getScroll().scrollTop = scroll.getScroll().scrollHeight; - await wait(50); + scroll.getScroll().scrollTop = scroll.getScroll().scrollHeight; + await wait(50); - expect(() => fixture.detectChanges()).not.toThrow(); - }); + expect(() => fixture.detectChanges()).not.toThrow(); }); }); describe('Rendering', () => {