• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IgniteUI / igniteui-angular / 19227084655

10 Nov 2025 09:34AM UTC coverage: 91.605% (+0.005%) from 91.6%
19227084655

Pull #16246

github

web-flow
Merge 51f4a4622 into ecfcb2d85
Pull Request #16246: Update Combo and Simple Combo Keyboard Navigation & Add Escape Key behavior

13892 of 16294 branches covered (85.26%)

25 of 26 new or added lines in 5 files covered. (96.15%)

19 existing lines in 4 files now uncovered.

27890 of 30446 relevant lines covered (91.6%)

34567.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.04
/projects/igniteui-angular/src/lib/combo/combo.component.ts
1
import { NgClass, NgTemplateOutlet } from '@angular/common';
2
import {
3
    AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, OnDestroy, DOCUMENT,
4
    Optional, Inject, Injector, ViewChild, Input, Output, EventEmitter, HostListener, DoCheck, booleanAttribute,
5
} from '@angular/core';
6

7
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
8

9
import { IgxSelectionAPIService } from '../core/selection';
10
import { IBaseEventArgs, IBaseCancelableEventArgs, CancelableEventArgs } from '../core/utils';
11
import { IgxForOfDirective } from '../directives/for-of/for_of.directive';
12
import { IgxIconService } from '../icon/icon.service';
13
import { IgxRippleDirective } from '../directives/ripple/ripple.directive';
14
import { IgxButtonDirective } from '../directives/button/button.directive';
15
import { IgxInputGroupComponent } from '../input-group/input-group.component';
16
import { IgxComboItemComponent } from './combo-item.component';
17
import { IgxComboDropDownComponent } from './combo-dropdown.component';
18
import { IgxComboFilteringPipe, IgxComboGroupingPipe } from './combo.pipes';
19
import { IGX_COMBO_COMPONENT, IgxComboBaseDirective } from './combo.common';
20
import { IgxComboAddItemComponent } from './combo-add-item.component';
21
import { IgxComboAPIService } from './combo.api';
22
import { EditorProvider } from '../core/edit-provider';
23
import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from '../input-group/public_api';
24
import { IgxDropDownItemNavigationDirective } from '../drop-down/drop-down-navigation.directive';
25
import { IgxIconComponent } from '../icon/icon.component';
26
import { IgxSuffixDirective } from '../directives/suffix/suffix.directive';
27
import { IgxInputDirective } from '../directives/input/input.directive';
28
import { IgxReadOnlyInputDirective } from '../directives/input/read-only-input.directive';
29

30
/** Event emitted when an igx-combo's selection is changing */
31
export interface IComboSelectionChangingEventArgs extends IBaseCancelableEventArgs {
32
    /** An array containing the values that are currently selected */
33
    oldValue: any[];
34
    /** An array containing the values that will be selected after this event */
35
    newValue: any[];
36
    /** An array containing the items that are currently selected */
37
    oldSelection: any[];
38
    /** An array containing the items that will be selected after this event */
39
    newSelection: any[];
40
    /** An array containing the items that will be added to the selection (if any) */
41
    added: any[];
42
    /** An array containing the items that will be removed from the selection (if any) */
43
    removed: any[];
44
    /** The text that will be displayed in the combo text box */
45
    displayText: string;
46
    /** The user interaction that triggered the selection change */
47
    event?: Event;
48
}
49

50
/** Event emitted when the igx-combo's search input changes */
51
export interface IComboSearchInputEventArgs extends IBaseCancelableEventArgs {
52
    /** The text that has been typed into the search input */
53
    searchText: string;
54
}
55

56
export interface IComboItemAdditionEvent extends IBaseEventArgs, CancelableEventArgs {
57
    oldCollection: any[];
58
    addedItem: any;
59
    newCollection: any[];
60
}
61

62
/**
63
 * When called with sets A & B, returns A - B (as array);
64
 *
65
 * @hidden
66
 */
67
const diffInSets = (set1: Set<any>, set2: Set<any>): any[] => {
3✔
68
    const results = [];
235✔
69
    set1.forEach(entry => {
235✔
70
        if (!set2.has(entry)) {
625✔
71
            results.push(entry);
369✔
72
        }
73
    });
74
    return results;
235✔
75
};
76

77
/**
78
 *  Represents a drop-down list that provides editable functionalities, allowing users to choose an option from a predefined list.
79
 *
80
 * @igxModule IgxComboModule
81
 * @igxTheme igx-combo-theme
82
 * @igxKeywords combobox, combo selection
83
 * @igxGroup Grids & Lists
84
 *
85
 * @remarks
86
 * It provides the ability to filter items as well as perform selection with the provided data.
87
 * Additionally, it exposes keyboard navigation and custom styling capabilities.
88
 * @example
89
 * ```html
90
 * <igx-combo [itemsMaxHeight]="250" [data]="locationData"
91
 *  [displayKey]="'field'" [valueKey]="'field'"
92
 *  placeholder="Location(s)" searchPlaceholder="Search...">
93
 * </igx-combo>
94
 * ```
95
 */
96
@Component({
97
    selector: 'igx-combo',
98
    templateUrl: 'combo.component.html',
99
    providers: [
100
        IgxComboAPIService,
101
        { provide: IGX_COMBO_COMPONENT, useExisting: IgxComboComponent },
102
        { provide: NG_VALUE_ACCESSOR, useExisting: IgxComboComponent, multi: true }
103
    ],
104
    imports: [
105
        NgTemplateOutlet,
106
        NgClass,
107
        FormsModule,
108
        IgxInputGroupComponent,
109
        IgxInputDirective,
110
        IgxSuffixDirective,
111
        IgxIconComponent,
112
        IgxComboDropDownComponent,
113
        IgxDropDownItemNavigationDirective,
114
        IgxForOfDirective,
115
        IgxComboItemComponent,
116
        IgxComboAddItemComponent,
117
        IgxButtonDirective,
118
        IgxRippleDirective,
119
        IgxReadOnlyInputDirective,
120
        IgxComboFilteringPipe,
121
        IgxComboGroupingPipe
122
    ]
123
})
124
export class IgxComboComponent extends IgxComboBaseDirective implements AfterViewInit, ControlValueAccessor, OnInit,
3✔
125
    OnDestroy, DoCheck, EditorProvider {
126
    /**
127
     * Whether the combo's search box should be focused after the dropdown is opened.
128
     * When `false`, the combo's list item container will be focused instead
129
     */
130
    @Input({ transform: booleanAttribute })
131
    public autoFocusSearch = true;
296✔
132

133

134
    /**
135
     * Defines the placeholder value for the combo dropdown search field
136
     *
137
     * @deprecated in version 18.2.0. Replaced with values in the localization resource strings.
138
     *
139
     * ```typescript
140
     * // get
141
     * let myComboSearchPlaceholder = this.combo.searchPlaceholder;
142
     * ```
143
     *
144
     * ```html
145
     * <!--set-->
146
     * <igx-combo [searchPlaceholder]='newPlaceHolder'></igx-combo>
147
     * ```
148
     */
149
    @Input()
150
    public searchPlaceholder: string;
151

152
    /**
153
     * Emitted when item selection is changing, before the selection completes
154
     *
155
     * ```html
156
     * <igx-combo (selectionChanging)='handleSelection()'></igx-combo>
157
     * ```
158
     */
159
    @Output()
160
    public selectionChanging = new EventEmitter<IComboSelectionChangingEventArgs>();
296✔
161

162
    /** @hidden @internal */
163
    @ViewChild(IgxComboDropDownComponent, { static: true })
164
    public dropdown: IgxComboDropDownComponent;
165

166
    /** @hidden @internal */
167
    public get filteredData(): any[] | null {
168
        return this.disableFiltering ? this.data : this._filteredData;
9,367✔
169
    }
170
    /** @hidden @internal */
171
    public set filteredData(val: any[] | null) {
172
        this._filteredData = this.groupKey ? (val || []).filter((e) => e.isHeader !== true) : val;
9,749!
173
        this.checkMatch();
909✔
174
    }
175

176
    protected _prevInputValue = '';
296✔
177

178
    private _displayText: string;
179

180
    constructor(
181
        elementRef: ElementRef,
182
        cdr: ChangeDetectorRef,
183
        selectionService: IgxSelectionAPIService,
184
        comboAPI: IgxComboAPIService,
185
        @Inject(DOCUMENT) document: any,
186
        @Optional() @Inject(IGX_INPUT_GROUP_TYPE) _inputGroupType: IgxInputGroupType,
187
        @Optional() _injector: Injector,
188
        @Optional() @Inject(IgxIconService) _iconService?: IgxIconService,
189
    ) {
190
        super(elementRef, cdr, selectionService, comboAPI, document, _inputGroupType, _injector, _iconService);
296✔
191
        this.comboAPI.register(this);
296✔
192
    }
193

194
    @HostListener('keydown.ArrowDown', ['$event'])
195
    @HostListener('keydown.Alt.ArrowDown', ['$event'])
196
    public onArrowDown(event: Event) {
197
        event.preventDefault();
2✔
198
        event.stopPropagation();
2✔
199
        this.open();
2✔
200
    }
201

202
    @HostListener('keydown.Escape', ['$event'])
203
    public onEscape(event: Event) {
204
        if (this.collapsed) {
1✔
205
            this.deselectAllItems(true, event);
1✔
206
        }
207
    }
208

209
    /** @hidden @internal */
210
    public get displaySearchInput(): boolean {
211
        return !this.disableFiltering || this.allowCustomValues;
7,701✔
212
    }
213

214
    /**
215
     * @hidden @internal
216
     */
217
    public handleKeyUp(event: KeyboardEvent): void {
218
        // TODO: use PlatformUtil for keyboard navigation
219
        if (event.key === 'ArrowDown' || event.key === 'Down') {
12✔
220
            this.dropdown.focusedItem = this.dropdown.items[0];
6✔
221
            this.dropdownContainer.nativeElement.focus();
6✔
222
        } else if (event.key === 'Escape' || event.key === 'Esc') {
6✔
223
            this.toggle();
2✔
224
        }
225
    }
226

227
    /**
228
     * @hidden @internal
229
     */
230
    public handleSelectAll(evt) {
231
        if (evt.checked) {
2✔
232
            this.selectAllItems();
1✔
233
        } else {
234
            this.deselectAllItems();
1✔
235
        }
236
    }
237

238
    /**
239
     * @hidden @internal
240
     */
241
    public writeValue(value: any[]): void {
242
        const selection = Array.isArray(value) ? value.filter(x => x !== undefined) : [];
1,107✔
243
        const oldSelection = this.selection;
548✔
244
        this.selectionService.select_items(this.id, selection, true);
548✔
245
        this.cdr.markForCheck();
548✔
246
        this._displayValue = this.createDisplayText(this.selection, oldSelection);
548✔
247
        this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection;
1,078✔
248
    }
249

250
    /** @hidden @internal */
251
    public ngDoCheck(): void {
252
        if (this.data?.length && this.selection.length) {
3,765✔
253
            this._displayValue = this._displayText || this.createDisplayText(this.selection, []);
1,199✔
254
            this._value = this.valueKey ? this.selection.map(item => item[this.valueKey]) : this.selection;
5,200✔
255
        }
256
    }
257

258
    /**
259
     * @hidden
260
     */
261
    public getEditElement(): HTMLElement {
262
        return this.comboInput.nativeElement;
4✔
263
    }
264

265
    /**
266
     * @hidden @internal
267
     */
268
    public get context(): any {
UNCOV
269
        return {
×
270
            $implicit: this
271
        };
272
    }
273

274
    /**
275
     * @hidden @internal
276
     */
277
    public handleClearItems(event: Event): void {
278
        if (this.disabled) {
9✔
279
            return;
1✔
280
        }
281
        this.deselectAllItems(true, event);
8✔
282
        if (this.collapsed) {
8✔
283
            this.getEditElement().focus();
4✔
284
        } else {
285
            this.focusSearchInput(true);
4✔
286
        }
287
        event.stopPropagation();
8✔
288
    }
289

290
    /**
291
     * Select defined items
292
     *
293
     * @param newItems new items to be selected
294
     * @param clearCurrentSelection if true clear previous selected items
295
     * ```typescript
296
     * this.combo.select(["New York", "New Jersey"]);
297
     * ```
298
     */
299
    public select(newItems: Array<any>, clearCurrentSelection?: boolean, event?: Event) {
300
        if (newItems) {
80✔
301
            const newSelection = this.selectionService.add_items(this.id, newItems, clearCurrentSelection);
80✔
302
            this.setSelection(newSelection, event);
80✔
303
        }
304
    }
305

306
    /**
307
     * Deselect defined items
308
     *
309
     * @param items items to deselected
310
     * ```typescript
311
     * this.combo.deselect(["New York", "New Jersey"]);
312
     * ```
313
     */
314
    public deselect(items: Array<any>, event?: Event) {
315
        if (items) {
19✔
316
            const newSelection = this.selectionService.delete_items(this.id, items);
19✔
317
            this.setSelection(newSelection, event);
19✔
318
        }
319
    }
320

321
    /**
322
     * Select all (filtered) items
323
     *
324
     * @param ignoreFilter if set to true, selects all items, otherwise selects only the filtered ones.
325
     * ```typescript
326
     * this.combo.selectAllItems();
327
     * ```
328
     */
329
    public selectAllItems(ignoreFilter?: boolean, event?: Event) {
330
        const allVisible = this.selectionService.get_all_ids(ignoreFilter ? this.data : this.filteredData, this.valueKey);
5✔
331
        const newSelection = this.selectionService.add_items(this.id, allVisible);
5✔
332
        this.setSelection(newSelection, event);
5✔
333
    }
334

335
    /**
336
     * Deselect all (filtered) items
337
     *
338
     * @param ignoreFilter if set to true, deselects all items, otherwise deselects only the filtered ones.
339
     * ```typescript
340
     * this.combo.deselectAllItems();
341
     * ```
342
     */
343
    public deselectAllItems(ignoreFilter?: boolean, event?: Event): void {
344
        let newSelection = this.selectionService.get_empty();
13✔
345
        if (this.filteredData.length !== this.data.length && !ignoreFilter) {
13✔
346
            newSelection = this.selectionService.delete_items(this.id, this.selectionService.get_all_ids(this.filteredData, this.valueKey));
1✔
347
        }
348
        this.setSelection(newSelection, event);
13✔
349
    }
350

351
    /**
352
     * Selects/Deselects a single item
353
     *
354
     * @param itemID the itemID of the specific item
355
     * @param select If the item should be selected (true) or deselected (false)
356
     *
357
     * Without specified valueKey;
358
     * ```typescript
359
     * this.combo.valueKey = null;
360
     * const items: { field: string, region: string}[] = data;
361
     * this.combo.setSelectedItem(items[0], true);
362
     * ```
363
     * With specified valueKey;
364
     * ```typescript
365
     * this.combo.valueKey = 'field';
366
     * const items: { field: string, region: string}[] = data;
367
     * this.combo.setSelectedItem('Connecticut', true);
368
     * ```
369
     */
370
    public setSelectedItem(itemID: any, select = true, event?: Event): void {
×
371
        if (itemID === undefined) {
7!
UNCOV
372
            return;
×
373
        }
374
        if (select) {
7✔
375
            this.select([itemID], false, event);
4✔
376
        } else {
377
            this.deselect([itemID], event);
3✔
378
        }
379
    }
380

381
    /** @hidden @internal */
382
    public handleOpened() {
383
        this.triggerCheck();
55✔
384

385
        // Disabling focus of the search input should happen only when drop down opens.
386
        // During keyboard navigation input should receive focus, even the autoFocusSearch is disabled.
387
        // That is why in such cases focusing of the dropdownContainer happens outside focusSearchInput method.
388
        if (this.autoFocusSearch) {
55✔
389
            this.focusSearchInput(true);
52✔
390
        } else {
391
            this.dropdownContainer.nativeElement.focus();
3✔
392
        }
393
        this.opened.emit({ owner: this });
55✔
394
    }
395

396
    /** @hidden @internal */
397
    public focusSearchInput(opening?: boolean): void {
398
        if (this.displaySearchInput && this.searchInput) {
57✔
399
            this.searchInput.nativeElement.focus();
56✔
400
        } else {
401
            if (opening) {
1!
402
                this.dropdownContainer.nativeElement.focus();
1✔
403
            } else {
UNCOV
404
                this.comboInput.nativeElement.focus();
×
UNCOV
405
                this.toggle();
×
406
            }
407
        }
408
    }
409

410
    protected setSelection(selection: Set<any>, event?: Event): void {
411
        const currentSelection = this.selectionService.get(this.id);
117✔
412
        const removed = this.convertKeysToItems(diffInSets(currentSelection, selection));
117✔
413
        const added = this.convertKeysToItems(diffInSets(selection, currentSelection));
117✔
414
        const newValue = Array.from(selection);
117✔
415
        const oldValue = Array.from(currentSelection || []);
117!
416
        const newSelection = this.convertKeysToItems(newValue);
117✔
417
        const oldSelection = this.convertKeysToItems(oldValue);
117✔
418
        const displayText = this.createDisplayText(this.convertKeysToItems(newValue), oldValue);
117✔
419
        const args: IComboSelectionChangingEventArgs = {
117✔
420
            newValue,
421
            oldValue,
422
            newSelection,
423
            oldSelection,
424
            added,
425
            removed,
426
            event,
427
            owner: this,
428
            displayText,
429
            cancel: false
430
        };
431
        this.selectionChanging.emit(args);
117✔
432
        if (!args.cancel) {
117✔
433
            this.selectionService.select_items(this.id, args.newValue, true);
115✔
434
            this._value = args.newValue;
115✔
435
            if (displayText !== args.displayText) {
115✔
436
                this._displayValue = this._displayText = args.displayText;
2✔
437
            } else {
438
                this._displayValue = this.createDisplayText(this.selection, args.oldSelection);
113✔
439
            }
440
            this._onChangeCallback(args.newValue);
115✔
441
        } else if (this.isRemote) {
2✔
442
            this.registerRemoteEntries(diffInSets(selection, currentSelection), false);
1✔
443
        }
444
    }
445

446
    protected createDisplayText(newSelection: any[], oldSelection: any[]) {
447
        const selection = this.valueKey ? newSelection.map(item => item[this.valueKey]) : newSelection;
6,840✔
448
        return this.isRemote
1,976✔
449
            ? this.getRemoteSelection(selection, oldSelection)
450
            : this.concatDisplayText(newSelection);
451
    }
452

453
    protected getSearchPlaceholderText(): string {
454
        return this.searchPlaceholder ||
7,626✔
455
            (this.disableFiltering ? this.resourceStrings.igx_combo_addCustomValues_placeholder : this.resourceStrings.igx_combo_filter_search_placeholder);
1,255✔
456
    }
457

458
    /** Returns a string that should be populated in the combo's text box */
459
    private concatDisplayText(selection: any[]): string {
460
        const value = this.displayKey !== null && this.displayKey !== undefined ?
1,935✔
461
            selection.map(entry => entry[this.displayKey]).join(', ') :
6,838✔
462
            selection.join(', ');
463
        return value;
1,935✔
464
    }
465
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc