diff --git a/CHANGELOG.md b/CHANGELOG.md index 28a8a9bdc68..b1816b33c8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,9 @@ See the [Angular Package Format documentation](https://angular.io/guide/angular- ### New Features +- `IgxCombo`, `IgxSimpleCombo` + - Introduced the ability for Combo and Simple Combo to close the dropdown list and move the focus to the next focusable element on "Tab" press and clear the selection if the combo is collapsed on "Escape". + - `IgxGrid`, `IgxTreeGrid`, `IgxHierarchicalGrid` - Introduced a new cell merging feature that allows you to configure and merge cells in a column based on same data or other custom condition, into a single cell. diff --git a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts index 8cdbaf022ae..403090f2438 100644 --- a/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo-dropdown.component.ts @@ -155,7 +155,7 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I /** * @hidden @internal */ - public override onItemActionKey(key: DropDownActionKey) { + public override onItemActionKey(key: DropDownActionKey, event?: KeyboardEvent) { switch (key) { case DropDownActionKey.ENTER: this.handleEnter(); @@ -164,8 +164,10 @@ export class IgxComboDropDownComponent extends IgxDropDownComponent implements I this.handleSpace(); break; case DropDownActionKey.ESCAPE: - case DropDownActionKey.TAB: this.close(); + break; + case DropDownActionKey.TAB: + this.close(event); } } diff --git a/projects/igniteui-angular/combo/src/combo/combo.common.ts b/projects/igniteui-angular/combo/src/combo/combo.common.ts index 31b8db9df3e..ed36b6f6030 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.common.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.common.ts @@ -1213,7 +1213,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh return; } this.searchValue = ''; - if (!e.event) { + const isTab = (e.event as KeyboardEvent)?.key === 'Tab'; + if (!e.event || isTab) { this.comboInput?.nativeElement.focus(); } else { this._onTouchedCallback(); @@ -1233,13 +1234,8 @@ export abstract class IgxComboBaseDirective implements IgxComboBase, AfterViewCh event.stopPropagation(); this.close(); } - } - - /** @hidden @internal */ - public handleToggleKeyDown(eventArgs: KeyboardEvent) { - if (eventArgs.key === 'Enter' || eventArgs.key === ' ') { - eventArgs.preventDefault(); - this.toggle(); + if (event.key === 'Tab') { + this.close(); } } diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.html b/projects/igniteui-angular/combo/src/combo/combo.component.html index 9fa321d2514..60f2027d054 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.html +++ b/projects/igniteui-angular/combo/src/combo/combo.component.html @@ -20,7 +20,7 @@ @if (displayValue) { + (click)="handleClearItems($event)"> @if (clearIconTemplate) { } @@ -29,7 +29,7 @@ } } - + @if (toggleIconTemplate) { } 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 3f3be0f1da7..e428333c04e 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.spec.ts @@ -1798,15 +1798,93 @@ describe('igxCombo', () => { fixture.detectChanges(); expect(firstVisibleItem.classList.contains(CSS_CLASS_FOCUSED)).toBeTruthy(); })); - it('should close the dropdown list on pressing Tab key', fakeAsync(() => { + it('should close the dropdown list on pressing Tab key and focus the next focusable element', fakeAsync(() => { combo.toggle(); fixture.detectChanges(); const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + const dropdownList = fixture.debugElement.query(By.css(`.${CSS_CLASS_DROPDOWNLIST_SCROLL}`)).nativeElement; + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + + let focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + let selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); + expect(focusedItems.length).toEqual(0); + expect(selectedItems.length).toEqual(0); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownContent); + fixture.detectChanges(); + focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + expect(focusedItems.length).toEqual(1); + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); tick(); fixture.detectChanges(); expect(combo.collapsed).toBeTruthy(); + expect(document.activeElement).not.toEqual(combo.comboInput.nativeElement); + + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownContent); + fixture.detectChanges(); + focusedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_FOCUSED}`); + expect(focusedItems.length).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + selectedItems = dropdownList.querySelectorAll(`.${CSS_CLASS_SELECTED}`); + expect(selectedItems.length).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + expect(document.activeElement).not.toEqual(combo.comboInput.nativeElement); + })); + it('should clear the selection and preserve the focus when the combo is collapsed and Escape key is pressed', fakeAsync(() => { + combo.comboInput.nativeElement.focus(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + + combo.select([combo.data[0][combo.valueKey]]); + expect(combo.selection.length).toEqual(1); + fixture.detectChanges(); + + combo.onEscape(UIInteractions.getKeyboardEvent('keydown', 'Escape')); + tick(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + expect(combo.selection.length).toEqual(0); + })); + it('should close the combo and preserve the focus when Escape key is pressed', fakeAsync(() => { + combo.comboInput.nativeElement.focus(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + + combo.toggle(); + fixture.detectChanges(); + expect(combo.collapsed).toBeFalsy(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + UIInteractions.triggerEventHandlerKeyDown('ArrowDown', dropdownContent); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Escape', dropdownContent); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); })); }); describe('primitive data dropdown: ', () => { @@ -2137,37 +2215,6 @@ describe('igxCombo', () => { cancel: false }); }); - it('should toggle combo dropdown on Enter of the focused toggle icon', fakeAsync(() => { - spyOn(combo, 'toggle').and.callThrough(); - const toggleBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)); - - UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); - tick(); - fixture.detectChanges(); - expect(combo.toggle).toHaveBeenCalledTimes(1); - expect(combo.collapsed).toEqual(false); - - UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); - tick(); - fixture.detectChanges(); - expect(combo.toggle).toHaveBeenCalledTimes(2); - expect(combo.collapsed).toEqual(true); - })); - it('should clear the selection on Enter of the focused clear icon', () => { - const selectedItem_1 = combo.dropdown.items[1]; - combo.toggle(); - fixture.detectChanges(); - simulateComboItemClick(1); - expect(combo.selection[0]).toEqual(selectedItem_1.value); - expect(combo.value[0]).toEqual(selectedItem_1.value[combo.valueKey]); - - const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); - UIInteractions.triggerEventHandlerKeyDown('Enter', clearBtn); - fixture.detectChanges(); - expect(input.nativeElement.value).toEqual(''); - expect(combo.selection.length).toEqual(0); - expect(combo.value.length).toEqual(0); - }); it('should not be able to select group header', () => { spyOn(combo.selectionChanging, 'emit').and.callThrough(); combo.toggle(); diff --git a/projects/igniteui-angular/combo/src/combo/combo.component.ts b/projects/igniteui-angular/combo/src/combo/combo.component.ts index 5a54c9c7f0f..495dfaedd46 100644 --- a/projects/igniteui-angular/combo/src/combo/combo.component.ts +++ b/projects/igniteui-angular/combo/src/combo/combo.component.ts @@ -185,6 +185,13 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie this.open(); } + @HostListener('keydown.Escape', ['$event']) + public onEscape(event: Event) { + if (this.collapsed) { + this.deselectAllItems(true, event); + } + } + /** @hidden @internal */ public get displaySearchInput(): boolean { return !this.disableFiltering || this.allowCustomValues; @@ -253,7 +260,10 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie /** * @hidden @internal */ - public clearInput(event: Event): void { + public handleClearItems(event: Event): void { + if (this.disabled) { + return; + } this.deselectAllItems(true, event); if (this.collapsed) { this.getEditElement().focus(); @@ -263,26 +273,6 @@ export class IgxComboComponent extends IgxComboBaseDirective implements AfterVie event.stopPropagation(); } - /** - * @hidden @internal - */ - public handleClearItems(event: Event): void { - if (this.disabled) { - return; - } - this.clearInput(event); - } - - /** - * @hidden @internal - */ - public handleClearKeyDown(eventArgs: KeyboardEvent) { - if (eventArgs.key === 'Enter' || eventArgs.key === ' ') { - eventArgs.preventDefault(); - this.clearInput(eventArgs); - } - } - /** * Select defined items * diff --git a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts index 88be5af2b87..840a220c879 100644 --- a/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts +++ b/projects/igniteui-angular/drop-down/src/drop-down/drop-down-navigation.directive.ts @@ -71,8 +71,10 @@ export class IgxDropDownItemNavigationDirective implements IDropDownNavigationDi if (navKeys.indexOf(key) === -1) { // If key has appropriate function in DD return; } - event.preventDefault(); - event.stopPropagation(); + if (key !== 'tab') { // Prevent default behavior for all keys except Tab + event.preventDefault(); + event.stopPropagation(); + } } else { // If dropdown is closed, do nothing return; } diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts index 4fd473be534..10c81481d53 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-functions.spec.ts @@ -628,21 +628,20 @@ export class QueryBuilderFunctions { switch (i) { case 0: expect(element).toHaveClass('igx-input-group__input'); break; case 1: expect(element).toHaveClass('igx-input-group__input'); break; - case 2: expect(element).toHaveClass('igx-combo__toggle-button'); break; - case 3: expect(element).toHaveClass('igx-button'); + case 2: expect(element).toHaveClass('igx-button'); expect(element.innerText).toContain('and'); break; - case 4: expect(element).toHaveClass('igx-chip'); break; - case 5: expect(element).toHaveClass('igx-icon'); break; - case 6: expect(element).toHaveClass('igx-chip__remove'); break; - case 7: expect(element).toHaveClass('igx-chip'); break; - case 8: expect(element).toHaveClass('igx-icon'); break; - case 9: expect(element).toHaveClass('igx-chip__remove'); break; - case 10: expect(element).toHaveClass('igx-chip'); break; - case 11: expect(element).toHaveClass('igx-icon'); break; - case 12: expect(element).toHaveClass('igx-chip__remove'); break; - case 13: expect(element).toHaveClass('igx-button'); + case 3: expect(element).toHaveClass('igx-chip'); break; + case 4: expect(element).toHaveClass('igx-icon'); break; + case 5: expect(element).toHaveClass('igx-chip__remove'); break; + case 6: expect(element).toHaveClass('igx-chip'); break; + case 7: expect(element).toHaveClass('igx-icon'); break; + case 8: expect(element).toHaveClass('igx-chip__remove'); break; + case 9: expect(element).toHaveClass('igx-chip'); break; + case 10: expect(element).toHaveClass('igx-icon'); break; + case 11: expect(element).toHaveClass('igx-chip__remove'); break; + case 12: expect(element).toHaveClass('igx-button'); expect(element.innerText).toContain('Condition'); break; - case 14: expect(element).toHaveClass('igx-button'); + case 13: expect(element).toHaveClass('igx-button'); expect(element.innerText).toContain('Group'); break; } i++; diff --git a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html index 21252f137c7..25941a95d04 100644 --- a/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html +++ b/projects/igniteui-angular/simple-combo/src/simple-combo/simple-combo.component.html @@ -27,7 +27,7 @@ @if (hasSelectedItem) { + (click)="handleClear($event)"> @if (clearIconTemplate) { } @@ -45,8 +45,7 @@ } - + @if (toggleIconTemplate) { } 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 1030bcc7ab5..d7bff749028 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 @@ -68,7 +68,7 @@ describe('IgxSimpleCombo', () => { get: mockNgControl }); mockSelection.get.and.returnValue(new Set([])); - const platformUtil = null; + const platformUtil: any = { KEYMAP: {} }; const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'body': document.createElement('div'), 'defaultView': { @@ -178,6 +178,7 @@ describe('IgxSimpleCombo', () => { expect(combo.value).toEqual(selectedItem); }); it('should emit owner on `opening` and `closing`', () => { + platformUtil.KEYMAP.TAB = 'Tab'; combo.ngOnInit(); spyOn(combo.opening, 'emit').and.callThrough(); spyOn(combo.closing, 'emit').and.callThrough(); @@ -213,6 +214,7 @@ describe('IgxSimpleCombo', () => { combo.handleClosing(inputEvent); expect(inputEvent.cancel).toEqual(true); sub.unsubscribe(); + platformUtil.KEYMAP = null; }); it('should fire selectionChanging event on item selection', () => { const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); @@ -1079,6 +1081,57 @@ describe('IgxSimpleCombo', () => { tick(); fixture.detectChanges(); expect(combo.collapsed).toBeTruthy(); + + combo.open(); + fixture.detectChanges(); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + expect(dropdown.focusedItem).toBeTruthy(); + expect(dropdown.focusedItem.index).toEqual(1); + + UIInteractions.triggerEventHandlerKeyDown('Space', dropdownContent); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Tab', dropdownContent); + tick(); + fixture.detectChanges(); + expect(combo.collapsed).toBeTruthy(); + })); + + it('should close the dropdown list on pressing Escape key and preserve the focus', fakeAsync(() => { + combo.comboInput.nativeElement.focus(); + fixture.detectChanges(); + + combo.open(); + fixture.detectChanges(); + + const dropdownContent = fixture.debugElement.query(By.css(`.${CSS_CLASS_CONTENT}`)); + + combo.handleKeyUp(UIInteractions.getKeyboardEvent('keyup', 'ArrowDown')); + fixture.detectChanges(); + + UIInteractions.triggerEventHandlerKeyDown('Escape', dropdownContent); + tick(); + fixture.detectChanges(); + + expect(combo.collapsed).toBeTruthy(); + expect(document.activeElement).toEqual(input.nativeElement); + })); + + it('should clear the selection and preserve the focus when the combo is collapsed and Escape key is pressed', fakeAsync(() => { + combo.comboInput.nativeElement.focus(); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + + combo.select(combo.data[2][combo.valueKey]); + fixture.detectChanges(); + expect(combo.selection).toBeDefined(); + + combo.handleKeyDown(UIInteractions.getKeyboardEvent('keydown', 'Escape')); + fixture.detectChanges(); + expect(document.activeElement).toEqual(combo.comboInput.nativeElement); + expect(combo.selection).not.toBeDefined(); })); it('should clear the selection on tab/blur if the search text does not match any value', () => { @@ -1120,36 +1173,6 @@ describe('IgxSimpleCombo', () => { expect(combo.displayValue).toEqual('Wisconsin'); }); - it('should toggle combo dropdown on Enter of the focused toggle icon', fakeAsync(() => { - spyOn(combo, 'toggle').and.callThrough(); - const toggleBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_TOGGLEBUTTON}`)); - - UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); - tick(); - fixture.detectChanges(); - expect(combo.toggle).toHaveBeenCalledTimes(1); - expect(combo.collapsed).toEqual(false); - - UIInteractions.triggerEventHandlerKeyDown('Enter', toggleBtn); - tick(); - fixture.detectChanges(); - expect(combo.toggle).toHaveBeenCalledTimes(2); - expect(combo.collapsed).toEqual(true); - })); - - it('should clear the selection on Enter of the focused clear icon', () => { - combo.select(combo.data[2][combo.valueKey]); - fixture.detectChanges(); - expect(combo.selection).toBeDefined() - expect(input.nativeElement.value).toEqual('Massachusetts'); - - const clearBtn = fixture.debugElement.query(By.css(`.${CSS_CLASS_CLEARBUTTON}`)); - UIInteractions.triggerEventHandlerKeyDown('Enter', clearBtn); - fixture.detectChanges(); - expect(input.nativeElement.value.length).toEqual(0); - expect(combo.selection).not.toBeDefined(); - }); - it('should not filter the data when disableFiltering is true', () => { combo.disableFiltering = true; fixture.detectChanges(); 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 f1aa318af7d..62deda67cbd 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 @@ -347,6 +347,19 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co this.close(); } } + if (event.key === this.platformUtil.KEYMAP.ESCAPE) { + if (this.collapsed) { + const oldSelection = this.selection; + this.clearSelection(true); + if (this.selection !== oldSelection) { + this.comboInput.value = this.filterValue = this.searchValue = ''; + } + this.dropdown.focusedItem = null; + this.comboInput.focus(); + } else { + this.close(); + } + } this.composing = false; super.handleKeyDown(event); } @@ -395,7 +408,11 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co } /** @hidden @internal */ - public clearInput(event: Event): void { + public handleClear(event: Event): void { + if (this.disabled) { + return; + } + const oldSelection = this.selection; this.clearSelection(true); @@ -413,23 +430,6 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co this.comboInput.focus(); } - /** @hidden @internal */ - public handleClear(event: Event): void { - if (this.disabled) { - return; - } - - this.clearInput(event); - } - - /** @hidden @internal */ - public handleClearKeyDown(event: KeyboardEvent): void { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - this.clearInput(event); - } - } - /** @hidden @internal */ public handleOpened(): void { this.triggerCheck(); @@ -450,7 +450,8 @@ export class IgxSimpleComboComponent extends IgxComboBaseDirective implements Co this.composing = false; // explicitly update selection so that we don't have to force CD - this.textSelection.selected = true; + const isTab = (e.event as KeyboardEvent)?.key === this.platformUtil.KEYMAP.TAB; + this.textSelection.selected = !isTab; } /** @hidden @internal */