Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 257 additions & 1 deletion projects/igniteui-angular/src/lib/select/select.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -96,6 +96,9 @@ describe('igxSelect', () => {
IgxSelectTemplateFormComponent,
IgxSelectHeaderFooterComponent,
IgxSelectCDRComponent,
IgxSelectNestedControlFlowComponent,
IgxSelectGroupedItemsControlFlowComponent,
IgxSelectGroupsInControlFlowComponent,
IgxSelectWithIdComponent
]
}).compileComponents();
Expand Down Expand Up @@ -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<IgxSelectGroupedItemsControlFlowComponent>;
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<IgxSelectGroupsInControlFlowComponent>;
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);
Expand Down Expand Up @@ -3108,6 +3280,90 @@ class IgxSelectCDRComponent {
];
}

@Component({
template: `
<igx-select #select>
<label igxLabel>Even/Odd Select</label>
@for (item of items; track item; let e = $even) {
@if (e) {
<igx-select-item [value]="item">odd: {{ item }}</igx-select-item>
} @else {
<igx-select-item [value]="item">even: {{ item }}</igx-select-item>
}
Comment thread
Zneeky marked this conversation as resolved.
}
</igx-select>
`,
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: `
<igx-select #select>
<label igxLabel>Grouped items with nested control flow</label>
@for (group of groups; track group.label) {
<igx-select-item-group [label]="group.label">
@for (item of group.items; track item; let e = $even) {
@if (e) {
<igx-select-item [value]="item">odd: {{ item }}</igx-select-item>
} @else {
<igx-select-item [value]="item">even: {{ item }}</igx-select-item>
}
}
</igx-select-item-group>
}
</igx-select>
`,
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: `
<igx-select #select>
<label igxLabel>Groups inside nested control flow</label>
@for (group of groups; track group.label; let e = $even) {
@if (e) {
<igx-select-item-group [label]="group.label">
@for (item of group.items; track item) {
<igx-select-item [value]="item">{{ item }}</igx-select-item>
}
</igx-select-item-group>
} @else {
<igx-select-item-group [label]="group.label">
@for (item of group.items; track item) {
<igx-select-item [value]="item">{{ item }}</igx-select-item>
}
</igx-select-item-group>
}
}
</igx-select>
`,
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: `
<igx-select [id]="'id1'">
Expand Down
50 changes: 50 additions & 0 deletions projects/igniteui-angular/src/lib/select/select.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down Expand Up @@ -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<HTMLElement>();
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]));
Expand Down
5 changes: 5 additions & 0 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,11 @@
icon: 'web',
name: 'Reactive Form'
},
{
link: '/select',
icon: 'arrow_drop_down_circle',
name: 'Select'
},
{
link: '/slider',
icon: 'tab',
Expand Down Expand Up @@ -747,7 +752,7 @@
}
].sort((componentLink1, componentLink2) => componentLink1.name > componentLink2.name ? 1 : -1);

constructor(private router: Router, private iconService: IgxIconService) {

Check warning on line 755 in src/app/app.component.ts

View workflow job for this annotation

GitHub Actions / run-tests (22.x)

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject

Check warning on line 755 in src/app/app.component.ts

View workflow job for this annotation

GitHub Actions / run-tests (22.x)

Prefer using the inject() function over constructor parameter injection. Use Angular's migration schematic to automatically refactor: ng generate @angular/core:inject
iconService.setFamily('fa-solid', { className: 'fa', type: 'font', prefix: 'fa-'});
iconService.setFamily('fa-brands', { className: 'fab', type: 'font' });

Expand Down
5 changes: 5 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -211,6 +212,10 @@ export const appRoutes: Routes = [
path: 'combo-showcase',
component: ComboShowcaseSampleComponent
},
{
path: 'select',
component: SelectSampleComponent
},
{
path: 'expansionPanel',
component: ExpansionPanelSampleComponent
Expand Down
Loading
Loading