diff --git a/projects/igniteui-angular/src/lib/select/select.component.spec.ts b/projects/igniteui-angular/src/lib/select/select.component.spec.ts index 76a525181e9..3cc403226d9 100644 --- a/projects/igniteui-angular/src/lib/select/select.component.spec.ts +++ b/projects/igniteui-angular/src/lib/select/select.component.spec.ts @@ -1,6 +1,6 @@ import { Component, ViewChild, DebugElement, OnInit, ElementRef } 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'; @@ -96,6 +96,9 @@ describe('igxSelect', () => { IgxSelectTemplateFormComponent, IgxSelectHeaderFooterComponent, IgxSelectCDRComponent, + IgxSelectNestedControlFlowComponent, + IgxSelectGroupedItemsControlFlowComponent, + IgxSelectGroupsInControlFlowComponent, IgxSelectWithIdComponent ] }).compileComponents(); @@ -2619,6 +2622,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); @@ -3108,6 +3280,90 @@ class IgxSelectCDRComponent { ]; } +@Component({ + template: ` + + + @for (item of items; track item; let e = $even) { + @if (e) { + odd: {{ item }} + } @else { + even: {{ 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) { + odd: {{ item }} + } @else { + even: {{ 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/src/lib/select/select.component.ts b/projects/igniteui-angular/src/lib/select/select.component.ts index bf05b0e21a0..251562cc4e9 100644 --- a/projects/igniteui-angular/src/lib/select/select.component.ts +++ b/projects/igniteui-angular/src/lib/select/select.component.ts @@ -455,7 +455,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(); }); @@ -628,6 +634,50 @@ 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. + * 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; + 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); + } + } + + let nextReferenceNode: HTMLElement | null = null; + for (let index = orderedTopLevelNodes.length - 1; index >= 0; index--) { + const node = orderedTopLevelNodes[index]; + if (node.parentElement === container) { + nextReferenceNode = node; + continue; + } + if (container.contains(node)) { + continue; + } + container.insertBefore(node, nextReferenceNode); + nextReferenceNode = node; + } + } + private setSelection(item: IgxDropDownItemBaseDirective) { if (item && item.value !== undefined && item.value !== null) { this.selection.set(this.id, new Set([item])); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index c3d2d4d104b..dc7eaa984e5 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -527,6 +527,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 5234d396dc1..7edaf724341 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -72,6 +72,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'; @@ -211,6 +212,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..359e7039260 100644 --- a/src/app/select/select.sample.html +++ b/src/app/select/select.sample.html @@ -102,6 +102,20 @@

Select - disabled item

+
+

Select - @if/@else inside @for

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

Select - using Groups