From e8f016c1a782b49072d72171f73614f8536763c6 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 17 Apr 2026 09:49:24 +0300 Subject: [PATCH 1/5] feat(select): implement nested control flow support --- .../src/select/select.component.spec.ts | 256 +++++++++++++++++- .../select/src/select/select.component.ts | 32 +++ 2 files changed, 287 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/select/src/select/select.component.spec.ts b/projects/igniteui-angular/select/src/select/select.component.spec.ts index d0ee4095bc1..060a99a6557 100644 --- a/projects/igniteui-angular/select/src/select/select.component.spec.ts +++ b/projects/igniteui-angular/select/src/select/select.component.spec.ts @@ -1,6 +1,6 @@ import { Component, ViewChild, DebugElement, OnInit, ElementRef, inject, ChangeDetectorRef, DOCUMENT, Injector } from '@angular/core'; import { NgStyle } from '@angular/common'; -import { TestBed, tick, fakeAsync, waitForAsync, discardPeriodicTasks } from '@angular/core/testing'; +import { ComponentFixture, TestBed, tick, fakeAsync, waitForAsync, discardPeriodicTasks } from '@angular/core/testing'; import { FormsModule, UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators, ReactiveFormsModule, NgForm, NgControl } from '@angular/forms'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -95,6 +95,7 @@ describe('igxSelect', () => { IgxSelectTemplateFormComponent, IgxSelectHeaderFooterComponent, IgxSelectCDRComponent, + IgxSelectNestedControlFlowComponent, IgxSelectWithIdComponent ] }).compileComponents(); @@ -2618,6 +2619,175 @@ describe('igxSelect', () => { expect(selectCDR.value).toBe('ID'); }); }); + describe('Test nested control flow (@if/@else inside @for)', () => { + beforeEach(() => { + fixture = TestBed.createComponent(IgxSelectNestedControlFlowComponent); + fixture.detectChanges(); + select = fixture.componentInstance.select; + }); + + it('should render items when using @if/@else inside @for loop', fakeAsync(() => { + expect(select).toBeDefined(); + expect(fixture.componentInstance.items.length).toBe(5); + + // @ContentChildren should find items + expect(select.children.length).toBe(5); + expect(select.items.length).toBe(5); + + // Verify all items are accessible and have correct values + const itemValues = select.items.map(item => item.value); + expect(itemValues).toEqual(['One', 'Two', 'Three', 'Four', 'Five']); + + // Items should be rendered in the scroll container + const scrollContainer = fixture.debugElement.query(By.css('.igx-drop-down__list-scroll')); + const renderedItems = scrollContainer.nativeElement.querySelectorAll('igx-select-item'); + expect(renderedItems.length).toBe(5); + + // Verify dropdown can be opened and shows items + select.toggle(); + tick(); + fixture.detectChanges(); + + expect(select.collapsed).toBeFalsy(); + const listItems = fixture.debugElement.queryAll(By.css('.' + CSS_CLASS_DROPDOWN_LIST_ITEM)); + expect(listItems.length).toBe(5); + })); + + it('should handle selection with nested control flow items', fakeAsync(() => { + expect(select.value).toBeUndefined(); + + // Select an item + select.value = 'Three'; + fixture.detectChanges(); + tick(); + + expect(select.value).toBe('Three'); + expect(select.selectedItem).toBeDefined(); + expect(select.selectedItem.value).toBe('Three'); + })); + + it('should handle dynamic item changes with nested control flow', fakeAsync(() => { + expect(select.items.length).toBe(5); + + // Add more items + fixture.componentInstance.items.push('Six', 'Seven'); + fixture.detectChanges(); + tick(); + + expect(select.children.length).toBe(7); + expect(select.items.length).toBe(7); + + // Remove items + fixture.componentInstance.items = ['One', 'Two']; + fixture.detectChanges(); + tick(); + + expect(select.children.length).toBe(2); + expect(select.items.length).toBe(2); + })); + }); + describe('grouped items with nested control flow', () => { + let groupedFixture: ComponentFixture; + let groupedSelect: IgxSelectComponent; + + beforeEach(() => { + groupedFixture = TestBed.createComponent(IgxSelectGroupedItemsControlFlowComponent); + groupedFixture.detectChanges(); + groupedSelect = groupedFixture.componentInstance.select; + }); + + it('should render group labels and all items in the scroll container', fakeAsync(() => { + expect(groupedSelect).toBeDefined(); + // 3 items in Fruits + 2 in Veggies = 5 total + expect(groupedSelect.items.length).toBe(5); + + const scrollContainer = groupedFixture.debugElement.query(By.css('.igx-drop-down__list-scroll')); + const renderedGroups = scrollContainer.nativeElement.querySelectorAll('igx-select-item-group'); + expect(renderedGroups.length).toBe(2); + + const renderedItems = scrollContainer.nativeElement.querySelectorAll('igx-select-item'); + expect(renderedItems.length).toBe(5); + + // Group labels should be visible + const labels = scrollContainer.nativeElement.querySelectorAll('igx-select-item-group label'); + expect(labels[0].textContent.trim()).toBe('Fruits'); + expect(labels[1].textContent.trim()).toBe('Veggies'); + })); + + it('should handle selection of items inside groups with nested control flow', fakeAsync(() => { + groupedSelect.value = 'Banana'; + groupedFixture.detectChanges(); + tick(); + + expect(groupedSelect.value).toBe('Banana'); + expect(groupedSelect.selectedItem).toBeDefined(); + expect(groupedSelect.selectedItem.value).toBe('Banana'); + })); + + it('should open and show all items when dropdown is toggled', fakeAsync(() => { + groupedSelect.toggle(); + tick(); + groupedFixture.detectChanges(); + + expect(groupedSelect.collapsed).toBeFalsy(); + const listItems = groupedFixture.debugElement.queryAll(By.css('.' + CSS_CLASS_DROPDOWN_LIST_ITEM)); + expect(listItems.length).toBe(5); + })); + }); + + describe('groups inside nested control flow', () => { + let groupsFixture: ComponentFixture; + let groupsSelect: IgxSelectComponent; + + beforeEach(() => { + groupsFixture = TestBed.createComponent(IgxSelectGroupsInControlFlowComponent); + groupsFixture.detectChanges(); + groupsSelect = groupsFixture.componentInstance.select; + }); + + it('should render groups and items in scroll container when groups are in @for > @if', fakeAsync(() => { + expect(groupsSelect).toBeDefined(); + // 3 items in Fruits + 2 in Veggies = 5 total + expect(groupsSelect.items.length).toBe(5); + + const scrollContainer = groupsFixture.debugElement.query(By.css('.igx-drop-down__list-scroll')); + const renderedGroups = scrollContainer.nativeElement.querySelectorAll('igx-select-item-group'); + expect(renderedGroups.length).toBe(2); + + const renderedItems = scrollContainer.nativeElement.querySelectorAll('igx-select-item'); + expect(renderedItems.length).toBe(5); + })); + + it('should handle selection when groups are in @for > @if', fakeAsync(() => { + groupsSelect.value = 'Carrot'; + groupsFixture.detectChanges(); + tick(); + + expect(groupsSelect.value).toBe('Carrot'); + expect(groupsSelect.selectedItem).toBeDefined(); + expect(groupsSelect.selectedItem.value).toBe('Carrot'); + })); + + it('should handle dynamic group changes', fakeAsync(() => { + expect(groupsSelect.items.length).toBe(5); + + groupsFixture.componentInstance.groups = [ + { label: 'Fruits', items: ['Apple', 'Banana', 'Cherry'] }, + { label: 'Veggies', items: ['Carrot', 'Pea'] }, + { label: 'Grains', items: ['Rice', 'Wheat'] } + ]; + groupsFixture.detectChanges(); + tick(); + + // 3 (Fruits) + 2 (Veggies) + 2 (Grains) = 7 + expect(groupsSelect.items.length).toBe(7); + + // Items from the new group are accessible + const values = groupsSelect.items.map(i => i.value); + expect(values).toContain('Rice'); + expect(values).toContain('Wheat'); + })); + }); describe('Input with input group directives - hint, label, prefix, suffix: ', () => { beforeEach(() => { fixture = TestBed.createComponent(IgxSelectAffixComponent); @@ -3123,6 +3293,90 @@ class IgxSelectCDRComponent { ]; } +@Component({ + template: ` + + + @for (item of items; track item; let e = $even) { + @if (e) { + even: {{ item }} + } @else { + odd: {{ item }} + } + } + + `, + imports: [IgxSelectComponent, IgxSelectItemComponent, IgxLabelDirective] +}) +class IgxSelectNestedControlFlowComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + + public items: string[] = ['One', 'Two', 'Three', 'Four', 'Five']; +} + +@Component({ + template: ` + + + @for (group of groups; track group.label) { + + @for (item of group.items; track item; let e = $even) { + @if (e) { + even: {{ item }} + } @else { + odd: {{ item }} + } + } + + } + + `, + imports: [IgxSelectComponent, IgxSelectGroupComponent, IgxSelectItemComponent, IgxLabelDirective] +}) +class IgxSelectGroupedItemsControlFlowComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + + public groups: { label: string; items: string[] }[] = [ + { label: 'Fruits', items: ['Apple', 'Banana', 'Cherry'] }, + { label: 'Veggies', items: ['Carrot', 'Pea'] } + ]; +} + +@Component({ + template: ` + + + @for (group of groups; track group.label; let e = $even) { + @if (e) { + + @for (item of group.items; track item) { + {{ item }} + } + + } @else { + + @for (item of group.items; track item) { + {{ item }} + } + + } + } + + `, + imports: [IgxSelectComponent, IgxSelectGroupComponent, IgxSelectItemComponent, IgxLabelDirective] +}) +class IgxSelectGroupsInControlFlowComponent { + @ViewChild('select', { read: IgxSelectComponent, static: true }) + public select: IgxSelectComponent; + + public groups: { label: string; items: string[] }[] = [ + { label: 'Fruits', items: ['Apple', 'Banana', 'Cherry'] }, + { label: 'Veggies', items: ['Carrot', 'Pea'] } + ]; +} + @Component({ template: ` diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts index ef1ea836949..9ea814e1464 100644 --- a/projects/igniteui-angular/select/src/select/select.component.ts +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -416,7 +416,13 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec scrollStrategy: new AbsoluteScrollStrategy(), excludeFromOutsideClick: [this.inputGroup.element.nativeElement as HTMLElement] }; + + // Initial pass — moves items that Angular's content projection could not place + // (e.g. items nested inside @for > @if control flow blocks). + this.moveItemsToScrollContainer(); + const changes$ = this.children.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.moveItemsToScrollContainer(); this.setSelection(this.items.find(x => x.value === this.value)); this.cdr.detectChanges(); }); @@ -589,6 +595,32 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec this.cdr.markForCheck(); } + /** + * Moves any igx-select-item elements that Angular's content projection could not + * place in the scroll container (e.g. items nested inside @for > @if control flow). + * Items already inside the container (directly or within a projected group) are skipped. + * If an item is inside an igx-select-item-group that is itself unprojected, the whole + * group is moved instead of detaching the item from it. + */ + private moveItemsToScrollContainer(): void { + if (!this.children?.length || !this.scrollContainer) { + return; + } + const container = this.scrollContainer; + for (const child of this.children) { + const el: HTMLElement = child.element.nativeElement; + if (container.contains(el)) { + continue; + } + const groupEl = el.closest('igx-select-item-group') as HTMLElement | null; + if (groupEl && !container.contains(groupEl)) { + container.appendChild(groupEl); + } else if (!groupEl) { + container.appendChild(el); + } + } + } + private setSelection(item: IgxDropDownItemBaseDirective) { if (item && item.value !== undefined && item.value !== null) { this.selection.set(this.id, new Set([item])); From 0eda14ef9dcf7f1603d58f910f5c21fc9547375e Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 17 Apr 2026 09:49:54 +0300 Subject: [PATCH 2/5] feat(select): add select sample --- src/app/app.component.ts | 5 +++++ src/app/app.routes.ts | 5 +++++ src/app/select/select.sample.html | 14 ++++++++++++++ src/app/select/select.sample.ts | 3 +++ 4 files changed, 27 insertions(+) diff --git a/src/app/app.component.ts b/src/app/app.component.ts index ad0bb8ebc38..b73556ad50b 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -573,6 +573,11 @@ export class AppComponent implements OnInit { icon: 'web', name: 'Reactive Form' }, + { + link: '/select', + icon: 'arrow_drop_down_circle', + name: 'Select' + }, { link: '/slider', icon: 'tab', diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 2e189e47a3d..261daead1e5 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -74,6 +74,7 @@ import { DropDownSampleComponent } from './drop-down/drop-down.sample'; import { DropDownVirtualComponent } from './drop-down/drop-down-virtual/drop-down-virtual.component'; import { ComboSampleComponent } from './combo/combo.sample'; import { ComboShowcaseSampleComponent } from './combo-showcase/combo-showcase.sample'; +import { SelectSampleComponent } from './select/select.sample'; import { OverlaySampleComponent } from './overlay/overlay.sample'; import { OverlayAnimationSampleComponent } from './overlay/overlay-animation.sample'; import { OverlayPresetsSampleComponent } from './overlay/overlay-presets.sample'; @@ -219,6 +220,10 @@ export const appRoutes: Routes = [ path: 'combo-showcase', component: ComboShowcaseSampleComponent }, + { + path: 'select', + component: SelectSampleComponent + }, { path: 'expansionPanel', component: ExpansionPanelSampleComponent diff --git a/src/app/select/select.sample.html b/src/app/select/select.sample.html index a1dcd55c199..5cd1e419d27 100644 --- a/src/app/select/select.sample.html +++ b/src/app/select/select.sample.html @@ -102,6 +102,20 @@

Select - disabled item

+
+

TEST: @if/@else inside @for

+ + + @for (item of testItems; track item; let e = $even) { + @if (e) { + odd: {{ item }} + } @else { + even: {{ item }} + } + } + +
+

Select - using Groups

Date: Fri, 17 Apr 2026 10:35:04 +0300 Subject: [PATCH 3/5] fix(select): enhance control flow support with grouped items --- .../src/select/select.component.spec.ts | 10 +++--- .../select/src/select/select.component.ts | 33 +++++++++++++++---- 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/projects/igniteui-angular/select/src/select/select.component.spec.ts b/projects/igniteui-angular/select/src/select/select.component.spec.ts index 060a99a6557..dc13eabff30 100644 --- a/projects/igniteui-angular/select/src/select/select.component.spec.ts +++ b/projects/igniteui-angular/select/src/select/select.component.spec.ts @@ -96,6 +96,8 @@ describe('igxSelect', () => { IgxSelectHeaderFooterComponent, IgxSelectCDRComponent, IgxSelectNestedControlFlowComponent, + IgxSelectGroupedItemsControlFlowComponent, + IgxSelectGroupsInControlFlowComponent, IgxSelectWithIdComponent ] }).compileComponents(); @@ -3299,9 +3301,9 @@ class IgxSelectCDRComponent { @for (item of items; track item; let e = $even) { @if (e) { - even: {{ item }} - } @else { odd: {{ item }} + } @else { + even: {{ item }} } } @@ -3323,9 +3325,9 @@ class IgxSelectNestedControlFlowComponent { @for (item of group.items; track item; let e = $even) { @if (e) { - even: {{ item }} - } @else { odd: {{ item }} + } @else { + even: {{ item }} } } diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts index 9ea814e1464..b4bdcc60a4d 100644 --- a/projects/igniteui-angular/select/src/select/select.component.ts +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -601,23 +601,44 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec * Items already inside the container (directly or within a projected group) are skipped. * If an item is inside an igx-select-item-group that is itself unprojected, the whole * group is moved instead of detaching the item from it. + * Insertion position is derived from @ContentChildren order so that mixing + * normally-projected items with control-flow items preserves the template order. */ private moveItemsToScrollContainer(): void { if (!this.children?.length || !this.scrollContainer) { return; } const container = this.scrollContainer; + + // Build an ordered list of top-level nodes (group or standalone item) + // based on @ContentChildren order, deduplicating group entries. + const orderedTopLevelNodes: HTMLElement[] = []; + const seenNodes = new Set(); for (const child of this.children) { const el: HTMLElement = child.element.nativeElement; - if (container.contains(el)) { + const groupEl = el.closest('igx-select-item-group') as HTMLElement | null; + const topLevelNode = groupEl ?? el; + if (!seenNodes.has(topLevelNode)) { + seenNodes.add(topLevelNode); + orderedTopLevelNodes.push(topLevelNode); + } + } + + for (let index = 0; index < orderedTopLevelNodes.length; index++) { + const node = orderedTopLevelNodes[index]; + if (container.contains(node)) { continue; } - const groupEl = el.closest('igx-select-item-group') as HTMLElement | null; - if (groupEl && !container.contains(groupEl)) { - container.appendChild(groupEl); - } else if (!groupEl) { - container.appendChild(el); + // Find the next node already in the container and insert before it + // to preserve template order. + let referenceNode: HTMLElement | null = null; + for (let nextIndex = index + 1; nextIndex < orderedTopLevelNodes.length; nextIndex++) { + if (orderedTopLevelNodes[nextIndex].parentElement === container) { + referenceNode = orderedTopLevelNodes[nextIndex]; + break; + } } + container.insertBefore(node, referenceNode); } } From 9530f730bcb993f7431a8387d6712e06291ef2ef Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov <105818882+Zneeky@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:59:48 +0300 Subject: [PATCH 4/5] fix(select-sample): fix indentation Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/app/select/select.sample.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/select/select.sample.ts b/src/app/select/select.sample.ts index 0bfce1672b7..6237d73ee41 100644 --- a/src/app/select/select.sample.ts +++ b/src/app/select/select.sample.ts @@ -46,8 +46,8 @@ export class SelectSampleComponent implements OnInit { public fruits: string[] = ['Orange', 'Apple', 'Banana', 'Mango', 'Pear', 'Lemon', 'Peach', 'Apricot', 'Grapes', 'Cactus']; public selected: string; public selectRequired = true; - - //Test data for nested @if/@else issue + + // Test data for nested @if/@else issue public testItems: string[] = ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight']; public reactiveForm: UntypedFormGroup; From 39df8facb39c6b2c7feca64d626637bdb5dc6291 Mon Sep 17 00:00:00 2001 From: Arkan Ahmedov Date: Fri, 17 Apr 2026 12:42:14 +0300 Subject: [PATCH 5/5] fix(select): optimize control flow for node insertion and update sample title --- .../select/src/select/select.component.ts | 19 ++++++++----------- src/app/select/select.sample.html | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/projects/igniteui-angular/select/src/select/select.component.ts b/projects/igniteui-angular/select/src/select/select.component.ts index b4bdcc60a4d..c00192293cb 100644 --- a/projects/igniteui-angular/select/src/select/select.component.ts +++ b/projects/igniteui-angular/select/src/select/select.component.ts @@ -624,21 +624,18 @@ export class IgxSelectComponent extends IgxDropDownComponent implements IgxSelec } } - for (let index = 0; index < orderedTopLevelNodes.length; index++) { + let nextReferenceNode: HTMLElement | null = null; + for (let index = orderedTopLevelNodes.length - 1; index >= 0; index--) { const node = orderedTopLevelNodes[index]; - if (container.contains(node)) { + if (node.parentElement === container) { + nextReferenceNode = node; continue; } - // Find the next node already in the container and insert before it - // to preserve template order. - let referenceNode: HTMLElement | null = null; - for (let nextIndex = index + 1; nextIndex < orderedTopLevelNodes.length; nextIndex++) { - if (orderedTopLevelNodes[nextIndex].parentElement === container) { - referenceNode = orderedTopLevelNodes[nextIndex]; - break; - } + if (container.contains(node)) { + continue; } - container.insertBefore(node, referenceNode); + container.insertBefore(node, nextReferenceNode); + nextReferenceNode = node; } } diff --git a/src/app/select/select.sample.html b/src/app/select/select.sample.html index 5cd1e419d27..359e7039260 100644 --- a/src/app/select/select.sample.html +++ b/src/app/select/select.sample.html @@ -103,7 +103,7 @@

Select - disabled item

-

TEST: @if/@else inside @for

+

Select - @if/@else inside @for

@for (item of testItems; track item; let e = $even) {