From 64e5b7771c340700ffaa964482881976b416320f Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 25 Feb 2026 12:47:38 +0200 Subject: [PATCH 01/11] feat(simple-combo): add selectionChanged event --- CHANGELOG.md | 3 + .../simple-combo.component.spec.ts | 59 +++++++++++++++++++ .../simple-combo/simple-combo.component.ts | 35 +++++++++++ 3 files changed, 97 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34578518274..c32f3250df7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,9 @@ All notable changes for each version of this project will be documented in this - The overlay service now uses the Popover API to place overlay elements in the top layer, eliminating z-index stacking issues. - Improved positioning accuracy for container-based overlays with fixed container bounds. +- `IgxSimpleCombo` + - Introduced `selectionChanged` event. This event is non-cancelable and fires only after the selection is successful. + - **AI-Assisted Development - Copilot Skills** - Three consolidated Copilot Skills are now included in the repository to teach AI coding assistants/agents (e.g., GitHub Copilot, Cursor, Windsurf, Claude, JetBrains AI, etc.) how to work with Ignite UI for Angular: - **Components** - UI Components (form controls, layout, data display, feedback/overlays, directives — Input Group, Combo, Select, Date/Time Pickers, Calendar, Tabs, Stepper, Accordion, List, Card, Dialog, Snackbar, Button, Ripple, Tooltip, Drag and Drop, Layout Manager, Dock Manager and Charts (Area Chart, Bar Chart, Column Chart, Stock/Financial Chart, Pie Chart)) diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index 2c29c9a95d2..11dcd6faf08 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -256,6 +256,65 @@ describe('IgxSimpleCombo', () => { cancel: false }); }); + it('should fire selectionChanged event after item selection completes', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanged, 'emit'); + + let oldSelection = undefined; + let newSelection = combo.data[1]; + + combo.select(combo.data[1]); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue: undefined, + newValue: newSelection, + oldSelection: undefined, + newSelection: newSelection, + owner: combo, + displayText: newSelection.trim() + }); + + oldSelection = newSelection; + newSelection = combo.data[0]; + combo.select(combo.data[0]); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue: oldSelection, + newValue: newSelection, + oldSelection: oldSelection, + newSelection: newSelection, + owner: combo, + displayText: newSelection.trim() + }); + }); + it('should not fire selectionChanged event when selectionChanging is cancelled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + spyOn(combo.selectionChanging, 'emit').and.callFake((event: ISimpleComboSelectionChangingEventArgs) => { + event.cancel = true; + }); + spyOn(combo.selectionChanged, 'emit'); + + combo.select(combo.data[0]); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); + expect(combo.selection).toEqual(undefined); + }); it('should properly emit added and removed values in change event on single value selection', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts index aa73c06b7c7..635b7da5b50 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -27,6 +27,20 @@ export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventA displayText: string; } +/** Emitted when an igx-simple-combo's selection has been changed. */ +export interface ISimpleComboSelectionChangedEventArgs extends IBaseEventArgs { + /** An object which represents the value that was previously selected */ + oldValue: any; + /** An object which represents the value that is currently selected */ + newValue: any; + /** An object which represents the item that was previously selected */ + oldSelection: any; + /** An object which represents the item that is currently selected */ + newSelection: any; + /** The text that is displayed in the combo text box */ + displayText: string; +} + /** * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list. * @@ -78,6 +92,16 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co @Output() public selectionChanging = new EventEmitter(); + /** + * Emitted when item selection is changed, after the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanged = new EventEmitter(); + @ViewChild(IgxTextSelectionDirective, { static: true }) private textSelection: IgxTextSelectionDirective; @@ -515,6 +539,17 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co : this.createDisplayText(super.selection, [args.oldValue]); } this._onChangeCallback(args.newValue); + if (args.newSelection !== args.oldSelection) { + const changedArgs: ISimpleComboSelectionChangedEventArgs = { + newValue: args.newValue, + oldValue: args.oldValue, + newSelection: args.newSelection, + oldSelection: args.oldSelection, + displayText: this._displayValue, + owner: this + }; + this.selectionChanged.emit(changedArgs); + } this._updateInput = true; } else if (this.isRemote) { this.registerRemoteEntries(newValueAsArray, false); From 5e4259723e8709735c9af03598ba0b7f4ab3121b Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 4 Mar 2026 13:34:06 +0200 Subject: [PATCH 02/11] feat(simple-combo): add cancelable selectionChanged --- .../simple-combo.component.spec.ts | 43 +++++++++++++++++-- .../simple-combo/simple-combo.component.ts | 42 ++++++++++-------- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index 11dcd6faf08..d4b7544a341 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -10,7 +10,7 @@ import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxInputState, IgxLabelDirective } from '../../../input-group/src/public_api'; import { AbsoluteScrollStrategy, AutoPositionStrategy, ConnectedPositioningStrategy } from 'igniteui-angular/core'; import { UIInteractions, wait } from '../../../test-utils/ui-interactions.spec'; -import { IgxSimpleComboComponent, ISimpleComboSelectionChangingEventArgs } from './public_api'; +import { IgxSimpleComboComponent, ISimpleComboSelectionChangedEventArgs, ISimpleComboSelectionChangingEventArgs } from './public_api'; import { IGX_GRID_DIRECTIVES, IgxGridComponent } from 'igniteui-angular/grids/grid'; import { IComboSelectionChangingEventArgs, IgxComboAPIService, IgxComboDropDownComponent, IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboItemDirective, IgxComboToggleIconDirective } from 'igniteui-angular/combo'; import { RemoteDataService } from 'igniteui-angular/combo/src/combo/combo.component.spec'; @@ -278,7 +278,8 @@ describe('IgxSimpleCombo', () => { oldSelection: undefined, newSelection: newSelection, owner: combo, - displayText: newSelection.trim() + displayText: newSelection.trim(), + cancel: false }); oldSelection = newSelection; @@ -291,8 +292,44 @@ describe('IgxSimpleCombo', () => { oldSelection: oldSelection, newSelection: newSelection, owner: combo, - displayText: newSelection.trim() + displayText: newSelection.trim(), + cancel: false + }); + }); + it('should rollback selection to previous value when selectionChanged event is cancelled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); + comboInput.value = 'test'; + combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + combo.select(combo.data[0]); + expect(combo.selection).toEqual(combo.data[0]); + expect(combo.value).toEqual(combo.data[0]); + + let capturedArgs: ISimpleComboSelectionChangedEventArgs; + spyOn(combo.selectionChanged, 'emit').and.callFake((event: ISimpleComboSelectionChangedEventArgs) => { + capturedArgs = { ...event }; + event.cancel = true; }); + spyOn(combo.selectionChanging, 'emit').and.callThrough(); + + combo.select(combo.data[1]); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); + + expect(capturedArgs.oldValue).toEqual(combo.data[0]); + expect(capturedArgs.newValue).toEqual(combo.data[1]); + expect(capturedArgs.oldSelection).toEqual(combo.data[0]); + expect(capturedArgs.newSelection).toEqual(combo.data[1]); + expect(capturedArgs.cancel).toBeFalse(); + + expect(combo.selection).toEqual(combo.data[0]); + expect(combo.value).toEqual(combo.data[0]); }); it('should not fire selectionChanged event when selectionChanging is cancelled', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts index 635b7da5b50..64671fe7fb9 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -28,18 +28,7 @@ export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventA } /** Emitted when an igx-simple-combo's selection has been changed. */ -export interface ISimpleComboSelectionChangedEventArgs extends IBaseEventArgs { - /** An object which represents the value that was previously selected */ - oldValue: any; - /** An object which represents the value that is currently selected */ - newValue: any; - /** An object which represents the item that was previously selected */ - oldSelection: any; - /** An object which represents the item that is currently selected */ - newSelection: any; - /** The text that is displayed in the combo text box */ - displayText: string; -} +export interface ISimpleComboSelectionChangedEventArgs extends ISimpleComboSelectionChangingEventArgs, CancelableEventArgs {} /** * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list. @@ -522,6 +511,8 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co owner: this, cancel: false }; + const previousValue = this.value; + const previousSelection = this.selection; if (args.newSelection !== args.oldSelection) { this.selectionChanging.emit(args); } @@ -538,18 +529,31 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co ? args.displayText : this.createDisplayText(super.selection, [args.oldValue]); } - this._onChangeCallback(args.newValue); - if (args.newSelection !== args.oldSelection) { + if (this.value !== previousValue || this.selection !== previousSelection) { const changedArgs: ISimpleComboSelectionChangedEventArgs = { - newValue: args.newValue, - oldValue: args.oldValue, - newSelection: args.newSelection, - oldSelection: args.oldSelection, + newValue: this.value, + oldValue: previousValue, + newSelection: this.selection, + oldSelection: previousSelection, displayText: this._displayValue, - owner: this + owner: this, + cancel: false }; this.selectionChanged.emit(changedArgs); + if (changedArgs.cancel) { + const rollbackSelection = this.isValid(previousValue) ? [previousValue] : []; + this.selectionService.select_items(this.id, rollbackSelection, true); + this._value = rollbackSelection; + const rollbackDisplayText = + rollbackSelection.length ? this.createDisplayText(super.selection, []) : ''; + this.comboInput.value = this._displayValue = this.searchValue = rollbackDisplayText; + this.filterValue = rollbackDisplayText; + this._onChangeCallback(previousValue); + this._updateInput = true; + return; + } } + this._onChangeCallback(args.newValue); this._updateInput = true; } else if (this.isRemote) { this.registerRemoteEntries(newValueAsArray, false); From 9eb3313f02c5749f26661069c912334421c2b23f Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 4 Mar 2026 13:34:27 +0200 Subject: [PATCH 03/11] feat(combo): add cancelable selectionChanged --- .../combo/src/combo/combo.common.ts | 1 + .../combo/src/combo/combo.component.spec.ts | 105 +++++++++++++++++- .../combo/src/combo/combo.component.ts | 37 +++++- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index 83c9a2c1903..a9a6bade24a 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -982,6 +982,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh public abstract dropdown: IgxComboDropDownComponent; public abstract selectionChanging: EventEmitter; + public abstract selectionChanged: EventEmitter; constructor() { onResourceChangeHandle(this.destroy$, () => { diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts index f226828ec82..e2597d0b06a 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -7,7 +7,6 @@ import { import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { BehaviorSubject, Observable, firstValueFrom } from 'rxjs'; -import { take } from 'rxjs/operators'; import { IgxSelectionAPIService } from 'igniteui-angular/core'; import { IBaseCancelableBrowserEventArgs } from 'igniteui-angular/core'; import { SortingDirection } from '../../../core/src/data-operations/sorting-strategy'; @@ -20,7 +19,7 @@ import { IgxComboDropDownComponent } from './combo-dropdown.component'; import { IgxComboItemComponent } from './combo-item.component'; import { IComboFilteringOptions, IGX_COMBO_COMPONENT } from './combo.common'; import { - IComboItemAdditionEvent, IComboSearchInputEventArgs, IComboSelectionChangingEventArgs, IgxComboComponent + IComboItemAdditionEvent, IComboSearchInputEventArgs, IComboSelectionChangedEventArgs, IComboSelectionChangingEventArgs, IgxComboComponent } from './combo.component'; import { IgxComboFooterDirective, IgxComboHeaderDirective, IgxComboItemDirective } from './combo.directives'; import { IgxComboFilteringPipe, comboIgnoreDiacriticsFilter } from './combo.pipes'; @@ -658,6 +657,108 @@ describe('igxCombo', () => { }); expect(selectionSpy).toHaveBeenCalledWith(expectedResults); }); + it('should fire selectionChanged event after item selection completes', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + spyOn(combo.selectionChanged, 'emit'); + + let oldValue = []; + let newValue = [combo.data[1], combo.data[5]]; + let oldSelection = []; + let newSelection = [combo.data[1], combo.data[5]]; + + combo.select(newSelection); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + added: newSelection, + removed: [], + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + + const newItem = combo.data[3]; + oldValue = [...newValue]; + newValue = [...newValue, newItem]; + oldSelection = [...newSelection]; + newSelection = [...newSelection, newItem]; + + combo.select([newItem]); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(2); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue, + newValue, + oldSelection, + newSelection, + added: [newItem], + removed: [], + event: undefined, + owner: combo, + displayText: `${newSelection.join(', ')}`, + cancel: false + }); + }); + it('should rollback selection to previous value when selectionChanged event is cancelled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + combo.select([combo.data[0], combo.data[1]]); + expect(combo.selection).toEqual([combo.data[0], combo.data[1]]); + expect(combo.value).toEqual([combo.data[0], combo.data[1]]); + + let capturedArgs: IComboSelectionChangedEventArgs; + spyOn(combo.selectionChanged, 'emit').and.callFake((event: IComboSelectionChangedEventArgs) => { + capturedArgs = { ...event }; + event.cancel = true; + }); + spyOn(combo.selectionChanging, 'emit').and.callThrough(); + + combo.select([combo.data[2], combo.data[3]], true); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); + + expect(capturedArgs.oldValue).toEqual([combo.data[0], combo.data[1]]); + expect(capturedArgs.newValue).toEqual([combo.data[2], combo.data[3]]); + expect(capturedArgs.oldSelection).toEqual([combo.data[0], combo.data[1]]); + expect(capturedArgs.newSelection).toEqual([combo.data[2], combo.data[3]]); + expect(capturedArgs.added).toEqual([combo.data[2], combo.data[3]]); + expect(capturedArgs.removed).toEqual([combo.data[0], combo.data[1]]); + expect(capturedArgs.cancel).toBeFalse(); + + expect(combo.selection).toEqual([combo.data[0], combo.data[1]]); + expect(combo.value).toEqual([combo.data[0], combo.data[1]]); + }); + it('should not fire selectionChanged event when selectionChanging is cancelled', () => { + const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); + combo.ngOnInit(); + combo.data = data; + combo.dropdown = dropdown; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); + + spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => { + event.cancel = true; + }); + spyOn(combo.selectionChanged, 'emit'); + + combo.select([combo.data[0], combo.data[1]]); + + expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); + expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); + }); it('should handle select/deselect ALL items', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index 495dfaedd46..1b47e4edfcd 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -42,6 +42,9 @@ export interface IComboSelectionChangingEventArgs extends IBaseCancelableEventAr event?: Event; } +/** Event emitted when an igx-combo's selection has been changed */ +export interface IComboSelectionChangedEventArgs extends IComboSelectionChangingEventArgs, CancelableEventArgs {} + /** Event emitted when the igx-combo's search input changes */ export interface IComboSearchInputEventArgs extends IBaseCancelableEventArgs { /** The text that has been typed into the search input */ @@ -125,7 +128,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie @Input({ transform: booleanAttribute }) public autoFocusSearch = true; - /** * Defines the placeholder value for the combo dropdown search field * @@ -154,6 +156,16 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie @Output() public selectionChanging = new EventEmitter(); + /** + * Emitted when item selection is changed, after the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanged = new EventEmitter(); + /** @hidden @internal */ @ViewChild(IgxComboDropDownComponent, { static: true }) public dropdown: IgxComboDropDownComponent; @@ -414,6 +426,8 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie displayText, cancel: false }; + const previousValue = [...this.value]; + const previousSelection = [...this.selection]; this.selectionChanging.emit(args); if (!args.cancel) { this.selectionService.select_items(this.id, args.newValue, true); @@ -424,6 +438,27 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } this._onChangeCallback(args.newValue); + if (this.value !== previousValue || this.selection !== previousSelection) { + const changedArgs: IComboSelectionChangedEventArgs = { + newValue: this.value, + oldValue: previousValue, + newSelection: this.selection, + oldSelection: previousSelection, + added: this.convertKeysToItems(diffInSets(new Set(this.value), new Set(previousValue))), + removed: this.convertKeysToItems(diffInSets(new Set(previousValue), new Set(this.value))), + event, + owner: this, + displayText: this._displayValue, + cancel: false + }; + this.selectionChanged.emit(changedArgs); + if (changedArgs.cancel) { + this.selectionService.select_items(this.id, previousValue, true); + this._value = previousValue; + this._displayValue = this._displayText = this.createDisplayText(previousSelection, []); + this._onChangeCallback(previousValue); + } + } } else if (this.isRemote) { this.registerRemoteEntries(diffInSets(selection, currentSelection), false); } From c2f8973fa651972fd9478fa4bc272c183c2caaee Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 4 Mar 2026 14:18:12 +0200 Subject: [PATCH 04/11] fix(combo): improve selection change handling with array equality check --- .../combo/src/combo/combo.component.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index 1b47e4edfcd..8230657088b 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -7,7 +7,8 @@ import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs, - EditorProvider + EditorProvider, + areEqualArrays } from 'igniteui-angular/core'; import { IgxForOfDirective } from 'igniteui-angular/directives'; import { IgxRippleDirective } from 'igniteui-angular/directives'; @@ -437,8 +438,7 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } else { this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } - this._onChangeCallback(args.newValue); - if (this.value !== previousValue || this.selection !== previousSelection) { + if (!areEqualArrays(this.value, previousValue) || !areEqualArrays(this.selection, previousSelection)) { const changedArgs: IComboSelectionChangedEventArgs = { newValue: this.value, oldValue: previousValue, @@ -455,8 +455,10 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie if (changedArgs.cancel) { this.selectionService.select_items(this.id, previousValue, true); this._value = previousValue; - this._displayValue = this._displayText = this.createDisplayText(previousSelection, []); + this._displayValue = this._displayText = this.createDisplayText(previousSelection, changedArgs.newSelection); this._onChangeCallback(previousValue); + } else { + this._onChangeCallback(args.newValue); } } } else if (this.isRemote) { From 9b7fbcf3bf17806312fb3c7fafe420fef32c1e08 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 4 Mar 2026 14:19:10 +0200 Subject: [PATCH 05/11] chore(changelog): include selectionChanged event for combo and simple-combo --- CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc067123e7a..5d41ca95759 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes for each version of this project will be documented in this file. +## 21.2.0 + +### New Features + +- `IgxCombo`, `IgxSimpleCombo` + - Introduced `selectionChanged` event for both components. This event is cancelable and fires only after the selection is successful. + ## 21.1.0 ### New Features @@ -52,9 +59,6 @@ All notable changes for each version of this project will be documented in this - The overlay service now uses the Popover API to place overlay elements in the top layer, eliminating z-index stacking issues. - Improved positioning accuracy for container-based overlays with fixed container bounds. -- `IgxSimpleCombo` - - Introduced `selectionChanged` event. This event is non-cancelable and fires only after the selection is successful. - - **AI-Assisted Development - Copilot Skills** - Three consolidated Copilot Skills are now included in the repository to teach AI coding assistants/agents (e.g., GitHub Copilot, Cursor, Windsurf, Claude, JetBrains AI, etc.) how to work with Ignite UI for Angular: - **Components** - UI Components (form controls, layout, data display, feedback/overlays, directives — Input Group, Combo, Select, Date/Time Pickers, Calendar, Tabs, Stepper, Accordion, List, Card, Dialog, Snackbar, Button, Ripple, Tooltip, Drag and Drop, Layout Manager, Dock Manager and Charts (Area Chart, Bar Chart, Column Chart, Stock/Financial Chart, Pie Chart)) From 78fbd7c40856b9ec650bceae35ad1a7013738dc2 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Wed, 4 Mar 2026 15:32:57 +0200 Subject: [PATCH 06/11] fix(combo): fix selection handling with previous state --- .../combo/src/combo/combo.component.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index 8230657088b..d73ad44e553 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -427,8 +427,11 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie displayText, cancel: false }; - const previousValue = [...this.value]; - const previousSelection = [...this.selection]; + const previousValue = [...oldValue]; + const previousSelection = [...oldSelection]; + const previousDisplayValue = this._displayValue; + const previousDisplayText = this._displayText; + const previousRemoteSelection = this.isRemote ? { ...this._remoteSelection } : null; this.selectionChanging.emit(args); if (!args.cancel) { this.selectionService.select_items(this.id, args.newValue, true); @@ -455,12 +458,16 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie if (changedArgs.cancel) { this.selectionService.select_items(this.id, previousValue, true); this._value = previousValue; - this._displayValue = this._displayText = this.createDisplayText(previousSelection, changedArgs.newSelection); + if (this.isRemote && previousRemoteSelection) { + this._remoteSelection = previousRemoteSelection; + } + this._displayValue = previousDisplayValue; + this._displayText = previousDisplayText; this._onChangeCallback(previousValue); - } else { - this._onChangeCallback(args.newValue); + return; } } + this._onChangeCallback(args.newValue); } else if (this.isRemote) { this.registerRemoteEntries(diffInSets(selection, currentSelection), false); } From 09199c1dfe254e9366dc42a1d9bc9a0c614c5093 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Fri, 6 Mar 2026 18:22:55 +0200 Subject: [PATCH 07/11] feat(simple-combo/combo): revert previous changes --- .../combo/src/combo/combo.common.ts | 1 - .../combo/src/combo/combo.component.ts | 47 +------------------ .../simple-combo/simple-combo.component.ts | 39 --------------- 3 files changed, 1 insertion(+), 86 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index a9a6bade24a..83c9a2c1903 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -982,7 +982,6 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh public abstract dropdown: IgxComboDropDownComponent; public abstract selectionChanging: EventEmitter; - public abstract selectionChanged: EventEmitter; constructor() { onResourceChangeHandle(this.destroy$, () => { diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index d73ad44e553..d767c392028 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -7,8 +7,7 @@ import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs, - EditorProvider, - areEqualArrays + EditorProvider } from 'igniteui-angular/core'; import { IgxForOfDirective } from 'igniteui-angular/directives'; import { IgxRippleDirective } from 'igniteui-angular/directives'; @@ -43,9 +42,6 @@ export interface IComboSelectionChangingEventArgs extends IBaseCancelableEventAr event?: Event; } -/** Event emitted when an igx-combo's selection has been changed */ -export interface IComboSelectionChangedEventArgs extends IComboSelectionChangingEventArgs, CancelableEventArgs {} - /** Event emitted when the igx-combo's search input changes */ export interface IComboSearchInputEventArgs extends IBaseCancelableEventArgs { /** The text that has been typed into the search input */ @@ -157,16 +153,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie @Output() public selectionChanging = new EventEmitter(); - /** - * Emitted when item selection is changed, after the selection completes - * - * ```html - * - * ``` - */ - @Output() - public selectionChanged = new EventEmitter(); - /** @hidden @internal */ @ViewChild(IgxComboDropDownComponent, { static: true }) public dropdown: IgxComboDropDownComponent; @@ -427,11 +413,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie displayText, cancel: false }; - const previousValue = [...oldValue]; - const previousSelection = [...oldSelection]; - const previousDisplayValue = this._displayValue; - const previousDisplayText = this._displayText; - const previousRemoteSelection = this.isRemote ? { ...this._remoteSelection } : null; this.selectionChanging.emit(args); if (!args.cancel) { this.selectionService.select_items(this.id, args.newValue, true); @@ -441,32 +422,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } else { this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } - if (!areEqualArrays(this.value, previousValue) || !areEqualArrays(this.selection, previousSelection)) { - const changedArgs: IComboSelectionChangedEventArgs = { - newValue: this.value, - oldValue: previousValue, - newSelection: this.selection, - oldSelection: previousSelection, - added: this.convertKeysToItems(diffInSets(new Set(this.value), new Set(previousValue))), - removed: this.convertKeysToItems(diffInSets(new Set(previousValue), new Set(this.value))), - event, - owner: this, - displayText: this._displayValue, - cancel: false - }; - this.selectionChanged.emit(changedArgs); - if (changedArgs.cancel) { - this.selectionService.select_items(this.id, previousValue, true); - this._value = previousValue; - if (this.isRemote && previousRemoteSelection) { - this._remoteSelection = previousRemoteSelection; - } - this._displayValue = previousDisplayValue; - this._displayText = previousDisplayText; - this._onChangeCallback(previousValue); - return; - } - } this._onChangeCallback(args.newValue); } else if (this.isRemote) { this.registerRemoteEntries(diffInSets(selection, currentSelection), false); diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts index 64671fe7fb9..aa73c06b7c7 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -27,9 +27,6 @@ export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventA displayText: string; } -/** Emitted when an igx-simple-combo's selection has been changed. */ -export interface ISimpleComboSelectionChangedEventArgs extends ISimpleComboSelectionChangingEventArgs, CancelableEventArgs {} - /** * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list. * @@ -81,16 +78,6 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co @Output() public selectionChanging = new EventEmitter(); - /** - * Emitted when item selection is changed, after the selection completes - * - * ```html - * - * ``` - */ - @Output() - public selectionChanged = new EventEmitter(); - @ViewChild(IgxTextSelectionDirective, { static: true }) private textSelection: IgxTextSelectionDirective; @@ -511,8 +498,6 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co owner: this, cancel: false }; - const previousValue = this.value; - const previousSelection = this.selection; if (args.newSelection !== args.oldSelection) { this.selectionChanging.emit(args); } @@ -529,30 +514,6 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co ? args.displayText : this.createDisplayText(super.selection, [args.oldValue]); } - if (this.value !== previousValue || this.selection !== previousSelection) { - const changedArgs: ISimpleComboSelectionChangedEventArgs = { - newValue: this.value, - oldValue: previousValue, - newSelection: this.selection, - oldSelection: previousSelection, - displayText: this._displayValue, - owner: this, - cancel: false - }; - this.selectionChanged.emit(changedArgs); - if (changedArgs.cancel) { - const rollbackSelection = this.isValid(previousValue) ? [previousValue] : []; - this.selectionService.select_items(this.id, rollbackSelection, true); - this._value = rollbackSelection; - const rollbackDisplayText = - rollbackSelection.length ? this.createDisplayText(super.selection, []) : ''; - this.comboInput.value = this._displayValue = this.searchValue = rollbackDisplayText; - this.filterValue = rollbackDisplayText; - this._onChangeCallback(previousValue); - this._updateInput = true; - return; - } - } this._onChangeCallback(args.newValue); this._updateInput = true; } else if (this.isRemote) { From 90b7fdf664a07a2542eb6227f36fdae742069c05 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Fri, 6 Mar 2026 19:01:57 +0200 Subject: [PATCH 08/11] test(simple-combo/combo): test scenarios for selectionChanged event lifecycle --- .../combo/src/combo/combo.component.spec.ts | 164 +++++++++++------- .../simple-combo.component.spec.ts | 149 ++++++++++------ 2 files changed, 196 insertions(+), 117 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts index e2597d0b06a..1c9bdf60f19 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -657,107 +657,82 @@ describe('igxCombo', () => { }); expect(selectionSpy).toHaveBeenCalledWith(expectedResults); }); - it('should fire selectionChanged event after item selection completes', () => { + it('should emit selectionChanged after selectionChanging with the committed state', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - spyOn(combo.selectionChanged, 'emit'); - let oldValue = []; - let newValue = [combo.data[1], combo.data[5]]; - let oldSelection = []; - let newSelection = [combo.data[1], combo.data[5]]; - - combo.select(newSelection); - expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); - expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ - oldValue, - newValue, - oldSelection, - newSelection, - added: newSelection, - removed: [], - event: undefined, - owner: combo, - displayText: `${newSelection.join(', ')}`, - cancel: false - }); + const callOrder: string[] = []; + spyOn(combo.selectionChanging, 'emit').and.callFake(() => callOrder.push('changing')); + spyOn(combo.selectionChanged, 'emit').and.callFake(() => callOrder.push('changed')); - const newItem = combo.data[3]; - oldValue = [...newValue]; - newValue = [...newValue, newItem]; - oldSelection = [...newSelection]; - newSelection = [...newSelection, newItem]; + const selectedItems = [combo.data[1], combo.data[5]]; + combo.select(selectedItems); - combo.select([newItem]); - expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(2); + expect(callOrder).toEqual(['changing', 'changed']); + expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ - oldValue, - newValue, - oldSelection, - newSelection, - added: [newItem], + oldValue: [], + newValue: selectedItems, + oldSelection: [], + newSelection: selectedItems, + added: selectedItems, removed: [], event: undefined, owner: combo, - displayText: `${newSelection.join(', ')}`, - cancel: false + displayText: selectedItems.join(', ') }); }); - it('should rollback selection to previous value when selectionChanged event is cancelled', () => { + it('should not emit selectionChanged when selectionChanging is canceled', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - combo.select([combo.data[0], combo.data[1]]); - expect(combo.selection).toEqual([combo.data[0], combo.data[1]]); - expect(combo.value).toEqual([combo.data[0], combo.data[1]]); - - let capturedArgs: IComboSelectionChangedEventArgs; - spyOn(combo.selectionChanged, 'emit').and.callFake((event: IComboSelectionChangedEventArgs) => { - capturedArgs = { ...event }; - event.cancel = true; + spyOn(combo.selectionChanging, 'emit').and.callFake((args: IComboSelectionChangingEventArgs) => { + args.cancel = true; }); - spyOn(combo.selectionChanging, 'emit').and.callThrough(); + spyOn(combo.selectionChanged, 'emit'); - combo.select([combo.data[2], combo.data[3]], true); + combo.select([combo.data[1], combo.data[5]]); expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); - expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); - - expect(capturedArgs.oldValue).toEqual([combo.data[0], combo.data[1]]); - expect(capturedArgs.newValue).toEqual([combo.data[2], combo.data[3]]); - expect(capturedArgs.oldSelection).toEqual([combo.data[0], combo.data[1]]); - expect(capturedArgs.newSelection).toEqual([combo.data[2], combo.data[3]]); - expect(capturedArgs.added).toEqual([combo.data[2], combo.data[3]]); - expect(capturedArgs.removed).toEqual([combo.data[0], combo.data[1]]); - expect(capturedArgs.cancel).toBeFalse(); - - expect(combo.selection).toEqual([combo.data[0], combo.data[1]]); - expect(combo.value).toEqual([combo.data[0], combo.data[1]]); + expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); + expect(combo.selection).toEqual([]); + expect(combo.value).toEqual([]); }); - it('should not fire selectionChanged event when selectionChanging is cancelled', () => { + it('should emit selectionChanged with the actual committed state when selectionChanging modifies newValue', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - spyOn(combo.selectionChanging, 'emit').and.callFake((event: IComboSelectionChangingEventArgs) => { - event.cancel = true; + spyOn(combo.selectionChanging, 'emit').and.callFake((args: IComboSelectionChangingEventArgs) => { + args.newValue = [combo.data[2], combo.data[4]]; + args.displayText = `${combo.data[2]}, ${combo.data[4]}`; }); + spyOn(combo.selectionChanged, 'emit'); combo.select([combo.data[0], combo.data[1]]); - expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); - expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); - expect(combo.selection).toEqual([]); - expect(combo.value).toEqual([]); + expect(combo.selection).toEqual([combo.data[2], combo.data[4]]); + expect(combo.value).toEqual([combo.data[2], combo.data[4]]); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue: [], + newValue: [combo.data[2], combo.data[4]], + oldSelection: [], + newSelection: [combo.data[2], combo.data[4]], + added: [combo.data[2], combo.data[4]], + removed: [], + event: undefined, + owner: combo, + displayText: `${combo.data[2]}, ${combo.data[4]}` + }); }); it('should handle select/deselect ALL items', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); @@ -955,7 +930,8 @@ describe('igxCombo', () => { ComboModelBindingComponent, IgxComboBindingDataAfterInitComponent, IgxComboFormComponent, - IgxComboInTemplatedFormComponent + IgxComboInTemplatedFormComponent, + ComboSelectionChangedNgModelOrderComponent ] }).compileComponents(); })); @@ -1608,6 +1584,23 @@ describe('igxCombo', () => { fixture.detectChanges(); expect(fixture.componentInstance.selectedItems).toEqual([...data].splice(1, 3)); })); + it('should emit selectionChanged after ngModelChange', fakeAsync(() => { + fixture = TestBed.createComponent(ComboSelectionChangedNgModelOrderComponent); + fixture.detectChanges(); + tick(); + + const host = fixture.componentInstance; + combo = host.combo; + + combo.select([1]); + fixture.detectChanges(); + tick(); + + expect(host.eventLog).toEqual(['ngModelChange', 'selectionChanged']); + expect(host.comboValue).toEqual([1]); + expect(host.changedArgs.newValue).toEqual([1]); + expect(host.changedArgs.newSelection).toEqual([host.items[1]]); + })); }); describe('Dropdown tests: ', () => { describe('complex data dropdown: ', () => { @@ -4092,3 +4085,42 @@ export class ComboWithIdComponent { ]; } } + +@Component({ + template: ` + + + `, + imports: [IgxComboComponent, FormsModule] +}) +class ComboSelectionChangedNgModelOrderComponent { + @ViewChild('combo', { read: IgxComboComponent, static: true }) + public combo: IgxComboComponent; + + public items = [ + { id: 0, text: 'One' }, + { id: 1, text: 'Two' }, + { id: 2, text: 'Three' } + ]; + + public comboValue: number[] = []; + public eventLog: string[] = []; + public changedArgs: IComboSelectionChangedEventArgs; + + public onNgModelChange(value: number[]): void { + this.eventLog.push('ngModelChange'); + this.comboValue = value; + } + + public onSelectionChanged(args: IComboSelectionChangedEventArgs): void { + this.eventLog.push('selectionChanged'); + this.changedArgs = args; + } +} diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts index d4b7544a341..73c7bfa723c 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.spec.ts @@ -256,101 +256,91 @@ describe('IgxSimpleCombo', () => { cancel: false }); }); - it('should fire selectionChanged event after item selection completes', () => { + it('should emit selectionChanged after selectionChanging with the committed state', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); comboInput.value = 'test'; combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - spyOn(combo.selectionChanged, 'emit'); - let oldSelection = undefined; - let newSelection = combo.data[1]; + const callOrder = []; + spyOn(combo.selectionChanging, 'emit').and.callFake(() => callOrder.push('changing')); + spyOn(combo.selectionChanged, 'emit').and.callFake(() => callOrder.push('changed')); combo.select(combo.data[1]); + + expect(callOrder).toEqual(['changing', 'changed']); expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ oldValue: undefined, - newValue: newSelection, + newValue: combo.data[1], oldSelection: undefined, - newSelection: newSelection, + newSelection: combo.data[1], owner: combo, - displayText: newSelection.trim(), - cancel: false - }); - - oldSelection = newSelection; - newSelection = combo.data[0]; - combo.select(combo.data[0]); - expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(2); - expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ - oldValue: oldSelection, - newValue: newSelection, - oldSelection: oldSelection, - newSelection: newSelection, - owner: combo, - displayText: newSelection.trim(), - cancel: false + displayText: combo.data[1].trim() }); }); - it('should rollback selection to previous value when selectionChanged event is cancelled', () => { + it('should not emit selectionChanged when selectionChanging is canceled', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); comboInput.value = 'test'; combo.comboInput = comboInput; - spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - combo.select(combo.data[0]); - expect(combo.selection).toEqual(combo.data[0]); - expect(combo.value).toEqual(combo.data[0]); + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - let capturedArgs: ISimpleComboSelectionChangedEventArgs; - spyOn(combo.selectionChanged, 'emit').and.callFake((event: ISimpleComboSelectionChangedEventArgs) => { - capturedArgs = { ...event }; - event.cancel = true; + spyOn(combo.selectionChanging, 'emit').and.callFake((args: ISimpleComboSelectionChangingEventArgs) => { + args.cancel = true; }); - spyOn(combo.selectionChanging, 'emit').and.callThrough(); + spyOn(combo.selectionChanged, 'emit'); combo.select(combo.data[1]); expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); - expect(combo.selectionChanged.emit).toHaveBeenCalledTimes(1); - - expect(capturedArgs.oldValue).toEqual(combo.data[0]); - expect(capturedArgs.newValue).toEqual(combo.data[1]); - expect(capturedArgs.oldSelection).toEqual(combo.data[0]); - expect(capturedArgs.newSelection).toEqual(combo.data[1]); - expect(capturedArgs.cancel).toBeFalse(); - - expect(combo.selection).toEqual(combo.data[0]); - expect(combo.value).toEqual(combo.data[0]); + expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); + expect(combo.selection).toBeUndefined(); + expect(combo.value).toBeUndefined(); }); - it('should not fire selectionChanged event when selectionChanging is cancelled', () => { + it('should emit selectionChanged with the actual committed state when selectionChanging modifies newValue', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; + const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); comboInput.value = 'test'; combo.comboInput = comboInput; + spyOnProperty(combo, 'totalItemCount').and.returnValue(combo.data.length); - spyOn(combo.selectionChanging, 'emit').and.callFake((event: ISimpleComboSelectionChangingEventArgs) => { - event.cancel = true; + spyOn(combo.selectionChanging, 'emit').and.callFake((args: ISimpleComboSelectionChangingEventArgs) => { + args.newValue = combo.data[2]; + args.newSelection = combo.data[2]; + args.displayText = combo.data[2]; }); + spyOn(combo.selectionChanged, 'emit'); - combo.select(combo.data[0]); + combo.select(combo.data[1]); - expect(combo.selectionChanging.emit).toHaveBeenCalledTimes(1); - expect(combo.selectionChanged.emit).not.toHaveBeenCalled(); - expect(combo.selection).toEqual(undefined); + expect(combo.selection).toEqual(combo.data[2]); + expect(combo.value).toEqual(combo.data[2]); + expect(combo.selectionChanged.emit).toHaveBeenCalledWith({ + oldValue: undefined, + newValue: combo.data[2], + oldSelection: undefined, + newSelection: combo.data[2], + owner: combo, + displayText: combo.data[2] + }); }); it('should properly emit added and removed values in change event on single value selection', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); @@ -534,7 +524,7 @@ describe('IgxSimpleCombo', () => { IgxSimpleComboEmptyComponent, IgxSimpleComboFormControlRequiredComponent, IgxSimpleComboFormWithFormControlComponent, - IgxSimpleComboNgModelComponent + IgxSimpleComboNgModelComponent, ] }).compileComponents(); })); @@ -924,7 +914,8 @@ describe('IgxSimpleCombo', () => { IgxSimpleComboSampleComponent, IgxComboInContainerTestComponent, IgxComboRemoteDataComponent, - ComboModelBindingComponent + ComboModelBindingComponent, + IgxSimpleComboNgModelChangeOrderComponent ] }).compileComponents(); })); @@ -1079,6 +1070,23 @@ describe('IgxSimpleCombo', () => { combo.select(combo.data[7][combo.valueKey]); expect(combo.displayValue).toEqual(combo.data[7][combo.displayKey]); })); + it('should emit selectionChanged after ngModelChange', fakeAsync(() => { + fixture = TestBed.createComponent(IgxSimpleComboNgModelChangeOrderComponent); + fixture.detectChanges(); + tick(); + + const host = fixture.componentInstance; + combo = host.combo; + + combo.select('California'); + fixture.detectChanges(); + tick(); + + expect(host.eventLog).toEqual(['ngModelChange', 'selectionChanged']); + expect(host.comboValue).toEqual('California'); + expect(host.changedArgs.newValue).toEqual('California'); + expect(host.changedArgs.newSelection).toEqual(jasmine.objectContaining({ field: 'California' })); + })); }); describe('Keyboard navigation and interactions', () => { @@ -3615,3 +3623,42 @@ export class IgxSimpleComboTabBehaviorTestComponent implements OnInit { ]; } } + +@Component({ + template: ` + + + `, + imports: [IgxSimpleComboComponent, FormsModule] +}) +class IgxSimpleComboNgModelChangeOrderComponent { + @ViewChild('combo', { read: IgxSimpleComboComponent, static: true }) + public combo: IgxSimpleComboComponent; + + public items = [ + { field: 'Arizona' }, + { field: 'California' }, + { field: 'Nevada' } + ]; + + public comboValue = 'Arizona'; + public eventLog: string[] = []; + public changedArgs: ISimpleComboSelectionChangedEventArgs; + + public onNgModelChange(value: any): void { + this.eventLog.push('ngModelChange'); + this.comboValue = value; + } + + public onSelectionChanged(args: ISimpleComboSelectionChangedEventArgs): void { + this.eventLog.push('selectionChanged'); + this.changedArgs = args; + } +} From be7adfea11f1d4c549529e30c83ba1aaa21cbc74 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Fri, 6 Mar 2026 20:30:11 +0200 Subject: [PATCH 09/11] feat(simple-combo/combo): add selectionChanged event --- .../combo/src/combo/combo.common.ts | 1 + .../combo/src/combo/combo.component.ts | 45 ++++++++++++++----- .../simple-combo/simple-combo.component.ts | 43 ++++++++++++++---- 3 files changed, 70 insertions(+), 19 deletions(-) diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index 83c9a2c1903..a9a6bade24a 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -982,6 +982,7 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh public abstract dropdown: IgxComboDropDownComponent; public abstract selectionChanging: EventEmitter; + public abstract selectionChanged: EventEmitter; constructor() { onResourceChangeHandle(this.destroy$, () => { diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index d767c392028..22ced534e34 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -22,26 +22,29 @@ import { IgxInputGroupComponent, IgxInputDirective, IgxReadOnlyInputDirective, I import { IgxIconComponent } from 'igniteui-angular/icon'; import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; -/** Event emitted when an igx-combo's selection is changing */ -export interface IComboSelectionChangingEventArgs extends IBaseCancelableEventArgs { - /** An array containing the values that are currently selected */ +/** Event emitted when an igx-combo's selection has been changed */ +export interface IComboSelectionChangedEventArgs extends IBaseEventArgs { + /** An array containing the values that were previously selected */ oldValue: any[]; - /** An array containing the values that will be selected after this event */ + /** An array containing the values that are currently selected */ newValue: any[]; - /** An array containing the items that are currently selected */ + /** An array containing the items that were previously selected */ oldSelection: any[]; - /** An array containing the items that will be selected after this event */ + /** An array containing the items that are currently selected */ newSelection: any[]; - /** An array containing the items that will be added to the selection (if any) */ + /** An array containing the items that were added to the selection (if any) */ added: any[]; - /** An array containing the items that will be removed from the selection (if any) */ + /** An array containing the items that were removed from the selection (if any) */ removed: any[]; - /** The text that will be displayed in the combo text box */ + /** The text that is displayed in the combo text box */ displayText: string; /** The user interaction that triggered the selection change */ event?: Event; } +/** Event emitted when an igx-combo's selection is changing */ +export interface IComboSelectionChangingEventArgs extends IComboSelectionChangedEventArgs, IBaseCancelableEventArgs {} + /** Event emitted when the igx-combo's search input changes */ export interface IComboSearchInputEventArgs extends IBaseCancelableEventArgs { /** The text that has been typed into the search input */ @@ -153,6 +156,16 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie @Output() public selectionChanging = new EventEmitter(); + /** + * Emitted when item selection is changed, after the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanged = new EventEmitter(); + /** @hidden @internal */ @ViewChild(IgxComboDropDownComponent, { static: true }) public dropdown: IgxComboDropDownComponent; @@ -422,7 +435,19 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie } else { this._displayValue = this.createDisplayText(this.selection, args.oldSelection); } - this._onChangeCallback(args.newValue); + this._onChangeCallback(this.value); + const changedArgs: IComboSelectionChangedEventArgs = { + newValue: this.value, + oldValue, + newSelection: this.selection, + oldSelection, + added: this.convertKeysToItems(diffInSets(new Set(this.value), new Set(oldValue))), + removed: this.convertKeysToItems(diffInSets(new Set(oldValue), new Set(this.value))), + event, + owner: this, + displayText: this._displayValue + }; + this.selectionChanged.emit(changedArgs); } else if (this.isRemote) { this.registerRemoteEntries(diffInSets(selection, currentSelection), false); } diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts index aa73c06b7c7..ec02cfd4966 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.ts @@ -13,20 +13,23 @@ import { IgxIconComponent } from 'igniteui-angular/icon'; import { IGX_COMBO_COMPONENT, IgxComboAddItemComponent, IgxComboAPIService, IgxComboBaseDirective, IgxComboDropDownComponent, IgxComboFilteringPipe, IgxComboGroupingPipe, IgxComboItemComponent } from 'igniteui-angular/combo'; import { IgxDropDownItemNavigationDirective } from 'igniteui-angular/drop-down'; -/** Emitted when an igx-simple-combo's selection is changing. */ -export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs { - /** An object which represents the value that is currently selected */ +/** Emitted when an igx-simple-combo's selection has been changed. */ +export interface ISimpleComboSelectionChangedEventArgs extends IBaseEventArgs { + /** An object which represents the value that was previously selected */ oldValue: any; - /** An object which represents the value that will be selected after this event */ + /** An object which represents the value that is currently selected */ newValue: any; - /** An object which represents the item that is currently selected */ + /** An object which represents the item that was previously selected */ oldSelection: any; - /** An object which represents the item that will be selected after this event */ + /** An object which represents the item that is currently selected */ newSelection: any; - /** The text that will be displayed in the combo text box */ + /** The text that is displayed in the combo text box */ displayText: string; } +/** Emitted when an igx-simple-combo's selection is changing. */ +export interface ISimpleComboSelectionChangingEventArgs extends ISimpleComboSelectionChangedEventArgs, CancelableEventArgs {} + /** * Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list. * @@ -78,6 +81,16 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co @Output() public selectionChanging = new EventEmitter(); + /** + * Emitted when item selection is changed, after the selection completes + * + * ```html + * + * ``` + */ + @Output() + public selectionChanged = new EventEmitter(); + @ViewChild(IgxTextSelectionDirective, { static: true }) private textSelection: IgxTextSelectionDirective; @@ -498,7 +511,8 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co owner: this, cancel: false }; - if (args.newSelection !== args.oldSelection) { + const shouldEmitSelectionEvents = args.newSelection !== args.oldSelection; + if (shouldEmitSelectionEvents) { this.selectionChanging.emit(args); } // TODO: refactor below code as it sets the selection and the display text @@ -514,7 +528,18 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co ? args.displayText : this.createDisplayText(super.selection, [args.oldValue]); } - this._onChangeCallback(args.newValue); + this._onChangeCallback(this.value); + if (shouldEmitSelectionEvents) { + const changedArgs: ISimpleComboSelectionChangedEventArgs = { + newValue: this.value, + oldValue: oldValueAsArray[0], + newSelection: this.selection, + oldSelection: oldItems[0], + displayText: this._displayValue, + owner: this + }; + this.selectionChanged.emit(changedArgs); + } this._updateInput = true; } else if (this.isRemote) { this.registerRemoteEntries(newValueAsArray, false); From 294204eec863d2540aa6e46348454059b3edd69c Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Mon, 9 Mar 2026 09:51:29 +0200 Subject: [PATCH 10/11] fix(changelog): clarify details of selectionChanged event --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d41ca95759..9d11c6d3398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ All notable changes for each version of this project will be documented in this ### New Features - `IgxCombo`, `IgxSimpleCombo` - - Introduced `selectionChanged` event for both components. This event is cancelable and fires only after the selection is successful. + - Introduced the `selectionChanged` event for both components. The event is not cancelable and is emitted after the selection is committed and the component state is updated. ## 21.1.0 From 6891cd48b37a2c46d4d25846281a48e60d5ac751 Mon Sep 17 00:00:00 2001 From: georgianastasov Date: Tue, 10 Mar 2026 10:13:01 +0200 Subject: [PATCH 11/11] feat(simple-combo/combo): document selection change events in README --- projects/igniteui-angular/combo/README.md | 13 ++++++++++++- projects/igniteui-angular/simple-combo/README.md | 12 +++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/combo/README.md b/projects/igniteui-angular/combo/README.md index d98765fcc4c..dd3b91ff3ea 100644 --- a/projects/igniteui-angular/combo/README.md +++ b/projects/igniteui-angular/combo/README.md @@ -86,6 +86,16 @@ export class MyCombo { } ``` +### Selection Events + +The `igx-combo` exposes both `selectionChanging` and `selectionChanged`. + +- `selectionChanging` is emitted **before** a new selection state is committed and can be canceled. +- If `selectionChanging` is not canceled, the component commits the final selection state and then emits `selectionChanged`. +- `selectionChanged` is emitted **after** the selection completes and the component state is updated. +- When the combo is used with `ngModel` or Angular forms, `selectionChanged` is emitted after the value change callback is invoked. +- `selectionChanged` is not cancelable and reports the final committed selection state. + ### Value Binding If we want to use a two-way data-binding, we could just use `ngModel` like this: @@ -330,7 +340,8 @@ When igxCombo is opened, allow custom values are enabled and add item button is | Name | Description | Cancelable | Emitted with | |---------------------|-------------------------------------------------------------------------|--------------|-----------------------------------| -| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | `IComboSelectionChangingEventArgs` | +| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | `IComboSelectionChangingEventArgs` | +| `selectionChanged` | Emitted after the selection completes and the component state has been updated | false | `IComboSelectionChangedEventArgs` | | `searchInputUpdate` | Emitted when an the search input's input event is triggered | true | `IComboSearchInputEventArgs` | | `addition` | Emitted when an item is being added to the data collection | true | `IComboItemAdditionEvent` | | `dataPreLoad` | Emitted when new chunk of data is loaded from the virtualization | false | `IForOfState` | diff --git a/projects/igniteui-angular/simple-combo/README.md b/projects/igniteui-angular/simple-combo/README.md index a854fdb969d..b836890173b 100644 --- a/projects/igniteui-angular/simple-combo/README.md +++ b/projects/igniteui-angular/simple-combo/README.md @@ -85,6 +85,15 @@ export class MyCombo { } ``` +### Selection Events + +The `igx-simple-combo` exposes both `selectionChanging` and `selectionChanged`. + +- `selectionChanging` is emitted **before** the selection is committed and can be canceled. +- `selectionChanged` is emitted **after** the selection is committed and the component state is updated. +- When the combo is used with `ngModel` or Angular forms, `selectionChanged` is emitted after the value change callback is invoked. +- `selectionChanged` is not cancelable and always reports the final committed single selection state. + ### Value Binding If we want to use a two-way data-binding, we could just use `ngModel` like this: @@ -305,7 +314,8 @@ When the combo is opened, allow custom values are enabled and add item button is ### Events | Name | Description | Cancelable | Parameters | |------------------ |-------------------------------------------------------------------------|------------- |-----------------------------------------| -| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | { oldSelection: `any`, newSelection: `any`, displayText: `string`, owner: `IgxSimpleComboComponent` } | +| `selectionChanging` | Emitted when item selection is changing, before the selection completes | true | { oldValue: `any`, newValue: `any`, oldSelection: `any`, newSelection: `any`, displayText: `string`, owner: `IgxSimpleComboComponent` } | +| `selectionChanged` | Emitted after the selection completes and the component state has been updated | false | { oldValue: `any`, newValue: `any`, oldSelection: `any`, newSelection: `any`, displayText: `string`, owner: `IgxSimpleComboComponent` } | | `searchInputUpdate` | Emitted when an the search input's input event is triggered | true | `IComboSearchInputEventArgs` | | `addition` | Emitted when an item is being added to the data collection | false | { oldCollection: `Array`, addedItem: ``, newCollection: `Array`, owner: `IgxSimpleComboComponent` }| | `onDataPreLoad` | Emitted when new chunk of data is loaded from the virtualization | false | `IForOfState` |