From a7c0b994942a32394bff770b59365baf2fdb3f27 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 7 Oct 2026 13:20:07 +0300 Subject: [PATCH 01/11] fix(snackbar): add type="button" --- CHANGELOG.md | 7 +++++++ .../snackbar/src/snackbar/snackbar.component.html | 2 +- .../snackbar/src/snackbar/snackbar.component.spec.ts | 12 ++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0b87e9817..89766acbc16 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. +## 23.0.0 + +### Bug Fixes + +- `IgxSnackbarComponent` + - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), pressing Enter in an input of the form submitted it. + ## 22.2.1 diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html index 37903af9b6a..6947c30ccf9 100644 --- a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.html @@ -6,7 +6,7 @@ @if (!customButton.children.length && actionText) { - } diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts index 638dc113101..2b23bf161cf 100644 --- a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts @@ -94,6 +94,18 @@ describe('IgxSnackbar', () => { expect(snackbar.clicked.emit).toHaveBeenCalledWith(snackbar); })); + it('should render the action button with type="button" to prevent form submission', fakeAsync(() => { + snackbar.actionText = 'undo'; + snackbar.displayTime = 100; + + snackbar.open(); + tick(100); + fixture.detectChanges(); + + const button = fixture.debugElement.query(By.css('button')).nativeElement as HTMLButtonElement; + expect(button.getAttribute('type')).toBe('button'); + })); + it('should emit opening when snackbar is shown', fakeAsync(() => { spyOn(snackbar.opening, 'emit'); snackbar.open(); From 81e6176fe37652c7b1921a3770b81c6b1a4a8c73 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 14 Oct 2026 11:21:29 +0300 Subject: [PATCH 02/11] fix(*): prevent implicit form submission from a grid --- CHANGELOG.md | 6 +- .../grids/grid/src/grid-base.directive.ts | 10 ++ .../grid/src/grid-form-submission.spec.ts | 126 ++++++++++++++++++ .../query-builder-tree.component.html | 2 + .../query-builder.component.spec.ts | 14 ++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43854de1073..76385ac7e5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,11 @@ All notable changes for each version of this project will be documented in this ### Bug Fixes - `IgxSnackbarComponent` - - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), pressing Enter in an input of the form submitted it. + - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), it became the default button of the form, so pressing Enter in an input of the form triggered the snackbar action instead of the form's submit button. +- `IgxQueryBuilderComponent` + - The add expression and the group (AND/OR) buttons now render with `type="button"`. Previously, clicking them inside a `form` submitted it. +- `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent` + - Pressing Enter in the grid UI, such as the filtering row, cell and row editors, the toolbar column hiding and pinning, Excel-style filtering and advanced filtering, no longer submits a surrounding `form`. ## Unreleased diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 8e30248b849..1de080b7aa8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -3659,6 +3659,16 @@ export abstract class IgxGridBaseDirective implements GridType, */ public _setupListeners() { const destructor = takeUntil(this.destroy$); + // Enter inside the grid must not trigger the implicit submission of a surrounding form. + // Capture phase, since internal handlers (filtering row, editors, dialogs) stop the propagation of key events. + // Buttons, links, textareas and contenteditable elements keep their native Enter behavior. + fromEvent(this.nativeElement, 'keydown', { capture: true }).pipe(destructor).subscribe((event) => { + const target = event.target as HTMLElement; + if (event.key === 'Enter' && !event.isComposing && !(target instanceof HTMLTextAreaElement) && !target.isContentEditable && + !target.closest('button, a[href], input[type=button], input[type=submit], input[type=reset], input[type=image]')) { + event.preventDefault(); + } + }); fromEvent(this.nativeElement, 'focusout').pipe(filter(() => !!this.navigation.activeNode), destructor).subscribe((event) => { const activeNode = this.navigation.activeNode; if (!this.crudService.cell && !!activeNode && diff --git a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts new file mode 100644 index 00000000000..ec97dc174d5 --- /dev/null +++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts @@ -0,0 +1,126 @@ +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { IgxGridComponent } from './grid.component'; +import { IgxColumnComponent, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; +import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { GridFunctions } from '../../../test-utils/grid-functions.spec'; + +const FILTER_UI_ROW = 'igx-grid-filtering-row'; +const UNTYPED_BUTTONS = 'button:not([type]), button[type=submit]'; + +const DATA = [ + { ProductID: 1, ProductName: 'Chai', InStock: true }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true } +]; + +/** Dispatches a cancelable Enter keydown, the event whose default action is the implicit form submission. */ +const pressEnter = (target: Element): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; +}; + +describe('IgxGrid - implicit form submission #grid', () => { + let fixture: ComponentFixture; + let grid: IgxGridComponent; + let form: HTMLFormElement; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, GridInFormComponent] + }).compileComponents(); + })); + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(GridInFormComponent); + fixture.detectChanges(); + // the toolbar actions are rendered once the grid has emitted `rendered` + tick(); + fixture.detectChanges(); + grid = fixture.componentInstance.grid; + form = fixture.nativeElement.querySelector('form'); + })); + + it('should not render buttons which can become the default button of the form', () => { + expect(grid.nativeElement.querySelectorAll(UNTYPED_BUTTONS).length).toBe(0); + // e.g. the add row snackbar action button must not take precedence over the form's own submit button + const defaultButton = form.querySelector('button:not([type]), button[type=submit], input[type=submit]'); + expect(defaultButton.id).toBe('submit'); + }); + + it('should prevent implicit submission on Enter in the filtering row input', fakeAsync(() => { + GridFunctions.clickFilterCellChipUI(fixture, 'ProductName'); + tick(); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css(FILTER_UI_ROW)).query(By.directive(IgxInputDirective)); + expect(input.nativeElement.form).toBe(form); + expect(pressEnter(input.nativeElement).defaultPrevented).toBeTrue(); + })); + + it('should prevent implicit submission on Enter in a cell editor', () => { + const cell = grid.getCellByColumn(0, 'ProductName'); + cell.editMode = true; + fixture.detectChanges(); + + const input: HTMLInputElement = grid.gridAPI.get_cell_by_index(0, 'ProductName').nativeElement.querySelector('input'); + expect(input.form).toBe(form); + expect(pressEnter(input).defaultPrevented).toBeTrue(); + }); + + it('should prevent implicit submission on Enter in the toolbar column hiding search input', fakeAsync(() => { + const hidingButton: HTMLButtonElement = fixture.nativeElement.querySelector('igx-grid-toolbar-hiding button'); + hidingButton.click(); + tick(); + fixture.detectChanges(); + + const input: HTMLInputElement = grid.nativeElement.querySelector('igx-column-actions input'); + expect(input.form).toBe(form); + expect(pressEnter(input).defaultPrevented).toBeTrue(); + })); + + it('should not prevent the native Enter activation of buttons inside the grid', () => { + const hidingButton: HTMLButtonElement = fixture.nativeElement.querySelector('igx-grid-toolbar-hiding button'); + expect(pressEnter(hidingButton).defaultPrevented).toBeFalse(); + }); + + it('should not affect implicit submission from inputs outside the grid', () => { + const outerInput: HTMLInputElement = fixture.nativeElement.querySelector('#outerInput'); + expect(pressEnter(outerInput).defaultPrevented).toBeFalse(); + }); +}); + +@Component({ + template: ` +
+ + + + + + + + + + + + +
`, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxGridToolbarComponent, + IgxGridToolbarActionsComponent, + IgxGridToolbarHidingComponent + ] +}) +class GridInFormComponent { + @ViewChild('grid', { static: true }) + public grid: IgxGridComponent; + + public data = DATA; +} diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html index c94e4787d24..54b88f2de15 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder-tree.component.html @@ -322,6 +322,7 @@
+ `, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + IgxGridComponent, + IgxColumnComponent, + IgxColumnActionsComponent, + IgxColumnHidingDirective, + IgxAdvancedFilteringDialogComponent + ] +}) +class GridComponentsInFormComponent { + @ViewChild('grid', { static: true }) + public grid!: IgxGridComponent; public data = DATA; } diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html index 6323dd02a2e..0c61c47e3af 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html @@ -7,6 +7,7 @@ tabindex="0" placeholder="Search" autocomplete="off" + (keydown.enter)="$event.preventDefault()" /> diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts index 85dce03a386..625a0451935 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts @@ -49,6 +49,16 @@ describe("Pivot data selector #pivotGrid", () => { expect(selector.dims).toEqual([]); expect(selector.values).toEqual([]); }); + + it("should prevent implicit form submission on Enter in its search input", () => { + const fixture = TestBed.createComponent(IgxPivotDataSelectorComponent); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement as HTMLInputElement; + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBeTrue(); + }); }); describe("Pivot data selector integration #pivotGrid", () => { From 14b7ad58904425652450daaac413bf5637e142e1 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Wed, 21 Oct 2026 15:22:02 +0300 Subject: [PATCH 05/11] chore(*): apply PR review suggestions --- CHANGELOG.md | 2 +- .../grids/core/src/column-actions/column-actions.component.html | 2 +- projects/igniteui-angular/grids/grid/src/grid-base.directive.ts | 2 +- .../grids/pivot-grid/src/pivot-data-selector.component.html | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c991bc0ddcc..6bb1c3d8461 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 ### Bug Fixes - **Forms** - - `IgxSnackbarComponent` - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), it became the default button of the form, so pressing Enter in an input of the form triggered the snackbar action instead of the form's submit button. + - `IgxSnackbarComponent` - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), it became the default button of the form, so pressing Enter in any input of the form clicked it, which triggered the snackbar action and submitted the form. - `IgxQueryBuilderComponent` - The add expression and the group (AND/OR) buttons now render with `type="button"`. Previously, clicking them inside a `form` submitted it. - `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent` - Pressing Enter in the grid UI, such as the filtering row, cell and row editors, the toolbar column hiding and pinning, Excel-style filtering and advanced filtering, no longer submits a surrounding `form`. - `IgxAdvancedFilteringDialogComponent`, `IgxColumnActionsComponent`, `IgxPivotDataSelectorComponent` - Pressing Enter in their inputs no longer submits a surrounding `form` when they are rendered outside of the grid, e.g. used standalone, or the advanced filtering dialog opened with custom overlay settings. diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html index f6038fbcaa7..897ccd3d433 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html @@ -14,7 +14,7 @@

} diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 6560f5f1c0b..984ef92b936 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -3661,7 +3661,7 @@ export abstract class IgxGridBaseDirective implements GridType, const destructor = takeUntil(this.destroy$); fromEvent(this.nativeElement, 'keydown', { capture: true }).pipe(destructor).subscribe((event) => { const target = event.target as HTMLElement; - if (event.key === 'Enter' && !event.isComposing && !(target instanceof HTMLTextAreaElement) && !target.isContentEditable && + if (event.key === this.platform.KEYMAP.ENTER && !event.isComposing && !(target instanceof HTMLTextAreaElement) && !target.isContentEditable && !target.closest('button, a[href], input[type=button], input[type=submit], input[type=reset], input[type=image]')) { event.preventDefault(); } diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html index 0c61c47e3af..30bccbcc492 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html @@ -7,7 +7,7 @@ tabindex="0" placeholder="Search" autocomplete="off" - (keydown.enter)="$event.preventDefault()" + (keydown.enter)="!$event.isComposing && $event.preventDefault()" /> From e8959c041e90096aa6bf8c2da2f7481198f80cd6 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Thu, 22 Oct 2026 15:05:23 +0300 Subject: [PATCH 06/11] fix(grid): resolve Enter target through shadow DOM composed path --- .../grids/grid/src/grid-base.directive.ts | 8 +++-- .../grid/src/grid-form-submission.spec.ts | 34 ++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index 8a82364896d..ec2091ddc93 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -3660,8 +3660,12 @@ export abstract class IgxGridBaseDirective implements GridType, public _setupListeners() { const destructor = takeUntil(this.destroy$); fromEvent(this.nativeElement, 'keydown', { capture: true }).pipe(destructor).subscribe((event) => { - const target = event.target as HTMLElement; - if (event.key === this.platform.KEYMAP.ENTER && !event.isComposing && !(target instanceof HTMLTextAreaElement) && !target.isContentEditable && + const target = event.composedPath()[0]; + if (!(target instanceof Element)) { + return; + } + if (event.key === this.platform.KEYMAP.ENTER && !event.isComposing && !(target instanceof HTMLTextAreaElement) && + !(target instanceof HTMLElement && target.isContentEditable) && !target.closest('button, a[href], input[type=button], input[type=submit], input[type=reset], input[type=image]')) { event.preventDefault(); } diff --git a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts index dde16024888..6e6fc1f66e5 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts @@ -1,9 +1,9 @@ -import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; -import { FilterMode, IgxAdvancedFilteringDialogComponent, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnHidingDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; +import { FilterMode, IgxAdvancedFilteringDialogComponent, IgxCellTemplateDirective, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnHidingDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; import { AbsoluteScrollStrategy, GlobalPositionStrategy } from 'igniteui-angular/core'; import { IgxInputDirective } from 'igniteui-angular/input-group'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; @@ -20,11 +20,22 @@ const DATA = [ ]; const pressEnter = (target: Element): KeyboardEvent => { - const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, composed: true }); target.dispatchEvent(event); return event; }; +const SHADOW_CONTROLS_TAG = 'igx-test-shadow-controls'; +if (!customElements.get(SHADOW_CONTROLS_TAG)) { + customElements.define(SHADOW_CONTROLS_TAG, class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }).innerHTML = + 'Link'; + } + }); +} + const addAdvancedFilteringCondition = async (fixture: ComponentFixture): Promise => { QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0); await fixture.whenStable(); @@ -144,6 +155,15 @@ describe('IgxGrid - implicit form submission #grid', () => { expect(pressEnter(hidingButton).defaultPrevented).toBe(false); }); + it('should not prevent the native Enter behavior of controls inside a shadow root in the grid', () => { + const shadowRoot = fixture.nativeElement.querySelector(SHADOW_CONTROLS_TAG).shadowRoot as ShadowRoot; + expect(grid.nativeElement.contains(shadowRoot.host)).toBe(true); + + expect(pressEnter(shadowRoot.querySelector('button span')!).defaultPrevented).toBe(false); + expect(pressEnter(shadowRoot.querySelector('textarea')!).defaultPrevented).toBe(false); + expect(pressEnter(shadowRoot.querySelector('a')!).defaultPrevented).toBe(false); + }); + it('should not affect implicit submission from inputs outside the grid', () => { const outerInput: HTMLInputElement = fixture.nativeElement.querySelector('#outerInput'); expect(pressEnter(outerInput).defaultPrevented).toBe(false); @@ -195,14 +215,20 @@ describe('IgxGrid - implicit form submission from grid components outside of the - + + + + + `, changeDetection: ChangeDetectionStrategy.Eager, + schemas: [CUSTOM_ELEMENTS_SCHEMA], imports: [ IgxGridComponent, IgxColumnComponent, + IgxCellTemplateDirective, IgxGridToolbarComponent, IgxGridToolbarActionsComponent, IgxGridToolbarHidingComponent From 39257e15d4667085d7f27985a12d4046e012d869 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 10 Nov 2026 11:27:12 +0200 Subject: [PATCH 07/11] fix(*): add single internal preventImplicitSubmission helper --- CHANGELOG.md | 4 +- .../igniteui-angular/core/src/core/utils.ts | 23 ++++++++ .../column-actions.component.html | 2 +- .../column-actions.component.ts | 7 ++- .../advanced-filtering-dialog.component.ts | 2 - .../grids/grid/src/grid-base.directive.ts | 19 +++---- .../grid/src/grid-form-submission.spec.ts | 55 +++++++++++++++++-- .../src/pivot-data-selector.component.html | 2 +- .../src/pivot-data-selector.component.ts | 7 ++- .../src/pivot-data-selector.spec.ts | 17 +++++- .../query-builder.component.spec.ts | 38 +++++++++++++ .../query-builder/query-builder.component.ts | 14 ++++- 12 files changed, 161 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b1ea56fcb..cda23914d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,8 @@ All notable changes for each version of this project will be documented in this - **Forms** - `IgxSnackbarComponent` - The default action button now renders with `type="button"`. Previously, when the snackbar was inside a `form` (for example as part of a grid), it became the default button of the form, so pressing Enter in any input of the form clicked it, which triggered the snackbar action and submitted the form. - - `IgxQueryBuilderComponent` - The add expression and the group (AND/OR) buttons now render with `type="button"`. Previously, clicking them inside a `form` submitted it. - - `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent` - Pressing Enter in the grid UI, such as the filtering row, cell and row editors, the toolbar column hiding and pinning, Excel-style filtering and advanced filtering, no longer submits a surrounding `form`. + - `IgxQueryBuilderComponent` - The add expression and the group (AND/OR) buttons now render with `type="button"`. Previously, clicking them inside a `form` submitted it. Pressing Enter in the query builder inputs also no longer submits a surrounding `form`. + - `IgxGridComponent`, `IgxTreeGridComponent`, `IgxHierarchicalGridComponent`, `IgxPivotGridComponent` - Pressing Enter in the grid UI, such as the filtering row, cell and row editors, the toolbar column hiding and pinning, Excel-style filtering and advanced filtering, as well as selecting a date in their date pickers with Enter, no longer submits a surrounding `form`. - `IgxAdvancedFilteringDialogComponent`, `IgxColumnActionsComponent`, `IgxPivotDataSelectorComponent` - Pressing Enter in their inputs no longer submits a surrounding `form` when they are rendered outside of the grid, e.g. used standalone, or the advanced filtering dialog opened with custom overlay settings. - `IgxAccordionComponent` - When `singleBranchExpand` is set initially and the panels are rendered with `@for`, the panels collapsed on init no longer keep `aria-expanded="true"` and no `ExpressionChangedAfterItHasBeenCheckedError` is thrown in development mode. diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index e1883918ce4..247dd03fc18 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -481,6 +481,29 @@ export const NAVIGATION_KEYS = new Set([ ' ' ]); +/** Input types on which Enter has its own activation behavior instead of submitting the form. */ +const ACTIVATABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'image', 'file', 'color']); + +/** + * @hidden + * @internal + * + * Cancels the implicit submission of the surrounding form, which the browser performs as the default action + * of an Enter keydown in an `` (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission). + * Browsers carry it out on the following `keypress`, which targets the focused element at that time, so handle both events + * where a widget (e.g. a picker selecting a date on Enter) may move the focus to a form input during the keydown. + * Only inputs which have a form owner and no activation behavior of their own are affected, + * so the native Enter behavior of any other element is preserved. + * The target is resolved through the composed path, as `event.target` is retargeted to the host for elements in a shadow root. + */ +export function preventImplicitSubmission(event: KeyboardEvent): void { + const target = event.composedPath()[0]; + if (event.key === 'Enter' && !event.isComposing && target instanceof HTMLInputElement && + target.form && !ACTIVATABLE_INPUT_TYPES.has(target.type)) { + event.preventDefault(); + } +} + /** * @hidden * @internal diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html index 897ccd3d433..04c697d9d45 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.html @@ -14,7 +14,7 @@

} diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts index 61de8da3ee9..e2cac29e28a 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts @@ -25,7 +25,7 @@ import { FormsModule } from '@angular/forms'; import { IgxInputDirective, IgxInputGroupComponent } from 'igniteui-angular/input-group'; import { IgxCheckboxComponent } from 'igniteui-angular/checkbox'; import { IgxButtonDirective, IgxRippleDirective } from 'igniteui-angular/directives'; -import { ColumnType } from 'igniteui-angular/core'; +import { ColumnType, preventImplicitSubmission } from 'igniteui-angular/core'; let NEXT_ID = 0; /** @@ -45,6 +45,11 @@ let NEXT_ID = 0; export class IgxColumnActionsComponent implements DoCheck { private differs = inject(IterableDiffers); + /** + * @hidden @internal + */ + protected readonly preventImplicitSubmission = preventImplicitSubmission; + /** * Gets/Sets the grid to provide column actions for. diff --git a/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts index 5b07cf67050..455949c93ab 100644 --- a/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts +++ b/projects/igniteui-angular/grids/core/src/filtering/advanced-filtering/advanced-filtering-dialog.component.ts @@ -141,8 +141,6 @@ export class IgxAdvancedFilteringDialogComponent implements OnDestroy { const key = eventArgs.key; if (key === this.platform.KEYMAP.ESCAPE) { this.closeDialog(); - } else if (key === this.platform.KEYMAP.ENTER && !eventArgs.isComposing && eventArgs.target instanceof HTMLInputElement) { - eventArgs.preventDefault(); } } diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index ec2091ddc93..eb4d47a8ef2 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -120,7 +120,7 @@ import { IgxPaginatorToken, type IgxPaginatorComponent } from 'igniteui-angular/ import { IgxSnackbarComponent } from 'igniteui-angular/snackbar'; import { CellType, CharSeparatedValueData, DropPosition, FilterMode, getUUID, GridCellMergeMode, GridKeydownTargetType, GridPagingMode, GridSelectionMode, GridServiceType, GridSummaryPosition, GridType, GridValidationTrigger, IActiveNode, IActiveNodeChangeEventArgs, ICellPosition, IClipboardOptions, IColumnMovingEndEventArgs, IColumnMovingEventArgs, IColumnMovingStartEventArgs, IColumnResizeEventArgs, IColumnSelectionEventArgs, IColumnVisibilityChangedEventArgs, IColumnVisibilityChangingEventArgs, IFilteringEventArgs, IGridCellEventArgs, IGridClipboardEvent, IGridContextMenuEventArgs, IGridEditDoneEventArgs, IGridEditEventArgs, IGridFormGroupCreatedEventArgs, IGridKeydownEventArgs, IGridRowEventArgs, IGridScrollEventArgs, IGridToolbarExportEventArgs, IGridValidationStatusEventArgs, IGX_GRID_SERVICE_BASE, IgxAdvancedFilteringDialogComponent, IgxCell, IgxColumnComponent, IgxColumnGroupComponent, IgxColumnResizingService, IgxDragIndicatorIconDirective, IgxEditRow, IgxExcelStyleHeaderIconDirective, IgxExcelStyleLoadingValuesTemplateDirective, IgxFilteringService, IgxGridBodyDirective, IgxGridCellComponent, IgxGridColumnResizerComponent, IgxGridEmptyTemplateContext, IgxGridEmptyTemplateDirective, IgxGridExcelStyleFilteringComponent, IgxGridFilteringCellComponent, IgxGridFilteringRowComponent, IgxGridHeaderComponent, IgxGridHeaderGroupComponent, IgxGridHeaderRowComponent, IgxGridHeaderTemplateContext, IgxGridLoadingTemplateDirective, IgxGridNavigationService, IgxGridPinningActionsComponent, IgxGridRowDragGhostContext, IgxGridRowEditActionsTemplateContext, IgxGridRowEditTemplateContext, IgxGridRowEditTextTemplateContext, IgxGridRowTemplateContext, IgxGridSelectionService, IgxGridSummaryService, IgxGridTemplateContext, IgxGridToolbarComponent, IgxGridValidationService, IgxHeaderCollapsedIndicatorDirective, IgxHeaderExpandedIndicatorDirective, IgxHeadSelectorDirective, IgxHeadSelectorTemplateContext, IgxRowAddTextDirective, IgxRowCollapsedIndicatorDirective, IgxRowDirective, IgxRowDragGhostDirective, IgxRowEditActionsDirective, IgxRowEditTabStopDirective, IgxRowEditTemplateDirective, IgxRowEditTextDirective, IgxRowExpandedIndicatorDirective, IgxRowSelectorDirective, IgxRowSelectorTemplateContext, IgxSortAscendingHeaderIconDirective, IgxSortDescendingHeaderIconDirective, IgxSortHeaderIconDirective, IgxSummaryRowComponent, IgxToolbarToken, IPinColumnCancellableEventArgs, IPinColumnEventArgs, IPinningConfig, IPinRowEventArgs, IRowDataCancelableEventArgs, IRowDataEventArgs, IRowDragEndEventArgs, IRowDragStartEventArgs, IRowSelectionEventArgs, IRowToggleEventArgs, ISearchInfo, ISizeInfo, ISortingEventArgs, RowEditPositionStrategy, RowPinningPosition, RowType, WatchChanges } from 'igniteui-angular/grids/core'; import { getCurrentI18n, getNumberFormatter, IResourceChangeEventArgs, } from 'igniteui-i18n-core'; -import { I18N_FORMATTER, IgxStylesRegistrar } from 'igniteui-angular/core'; +import { I18N_FORMATTER, IgxStylesRegistrar, preventImplicitSubmission } from 'igniteui-angular/core'; import { GRID_BASE_CSS } from './grid-base.styles'; /** @@ -3659,17 +3659,12 @@ export abstract class IgxGridBaseDirective implements GridType, */ public _setupListeners() { const destructor = takeUntil(this.destroy$); - fromEvent(this.nativeElement, 'keydown', { capture: true }).pipe(destructor).subscribe((event) => { - const target = event.composedPath()[0]; - if (!(target instanceof Element)) { - return; - } - if (event.key === this.platform.KEYMAP.ENTER && !event.isComposing && !(target instanceof HTMLTextAreaElement) && - !(target instanceof HTMLElement && target.isContentEditable) && - !target.closest('button, a[href], input[type=button], input[type=submit], input[type=reset], input[type=image]')) { - event.preventDefault(); - } - }); + // Capture phase, since internal handlers (filtering row, editors, dialogs) stop the propagation of key events. + // The keypress is also handled, as a picker selecting on Enter may move the focus to its input before it. + merge( + fromEvent(this.nativeElement, 'keydown', { capture: true }), + fromEvent(this.nativeElement, 'keypress', { capture: true }) + ).pipe(destructor).subscribe(preventImplicitSubmission); fromEvent(this.nativeElement, 'focusout').pipe(filter(() => !!this.navigation.activeNode), destructor).subscribe((event) => { const activeNode = this.navigation.activeNode; if (!this.crudService.cell && !!activeNode && diff --git a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts index 6e6fc1f66e5..2b91ab7f8d9 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts @@ -6,17 +6,20 @@ import { IgxGridComponent } from './grid.component'; import { FilterMode, IgxAdvancedFilteringDialogComponent, IgxCellTemplateDirective, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnHidingDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; import { AbsoluteScrollStrategy, GlobalPositionStrategy } from 'igniteui-angular/core'; import { IgxInputDirective } from 'igniteui-angular/input-group'; +import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; import { GridFunctions } from '../../../test-utils/grid-functions.spec'; import { QueryBuilderFunctions } from '../../../query-builder/src/query-builder/query-builder-functions.spec'; +import { firstValueFrom } from 'rxjs'; +import { userEvent } from 'vitest/browser'; import { describe, it, expect, beforeEach } from 'vitest'; const FILTER_UI_ROW = 'igx-grid-filtering-row'; const UNTYPED_BUTTONS = 'button:not([type]), button[type=submit]'; const DATA = [ - { ProductID: 1, ProductName: 'Chai', InStock: true }, - { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false }, - { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true } + { ProductID: 1, ProductName: 'Chai', InStock: true, Released: new Date(2024, 0, 10) }, + { ProductID: 2, ProductName: 'Aniseed Syrup', InStock: false, Released: new Date(2024, 1, 11) }, + { ProductID: 3, ProductName: 'Chef Antons Cajun Seasoning', InStock: true, Released: new Date(2024, 2, 12) } ]; const pressEnter = (target: Element): KeyboardEvent => { @@ -150,6 +153,36 @@ describe('IgxGrid - implicit form submission #grid', () => { await fixture.whenStable(); }); + it('should not submit the form on Enter in a filtering row date picker calendar, which moves the focus to its input', async () => { + GridFunctions.clickFilterCellChipUI(fixture, 'Released'); + await fixture.whenStable(); + fixture.detectChanges(); + + const filteringRow = fixture.debugElement.query(By.css(FILTER_UI_ROW)); + const datePicker = filteringRow.query(By.directive(IgxDatePickerComponent)).componentInstance as IgxDatePickerComponent; + const opened = firstValueFrom(datePicker.opened); + (datePicker.getEditElement() as HTMLElement).click(); + fixture.detectChanges(); + await opened; + await fixture.whenStable(); + fixture.detectChanges(); + expect(document.activeElement!.closest('igx-calendar')).not.toBeNull(); + + await userEvent.keyboard('{Enter}'); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(datePicker.collapsed).toBe(true); + expect((document.activeElement as HTMLInputElement).form).toBe(form); + expect(fixture.componentInstance.submits).toBe(0); + }); + + it('should submit the form on Enter in an input outside the grid', async () => { + await userEvent.click(fixture.nativeElement.querySelector('#outerInput')); + await userEvent.keyboard('{Enter}'); + expect(fixture.componentInstance.submits).toBe(1); + }); + it('should not prevent the native Enter activation of buttons inside the grid', () => { const hidingButton: HTMLButtonElement = fixture.nativeElement.querySelector('igx-grid-toolbar-hiding button'); expect(pressEnter(hidingButton).defaultPrevented).toBe(false); @@ -164,6 +197,15 @@ describe('IgxGrid - implicit form submission #grid', () => { expect(pressEnter(shadowRoot.querySelector('a')!).defaultPrevented).toBe(false); }); + it('should not prevent the native Enter behavior of other elements inside the grid', () => { + const cell: HTMLElement = fixture.nativeElement.querySelector(SHADOW_CONTROLS_TAG).parentElement; + expect(cell.querySelector('input[type=file]')!.form).toBe(form); + + expect(pressEnter(cell.querySelector('summary')!).defaultPrevented).toBe(false); + expect(pressEnter(cell.querySelector('input[type=file]')!).defaultPrevented).toBe(false); + expect(pressEnter(cell.querySelector('input[type=color]')!).defaultPrevented).toBe(false); + }); + it('should not affect implicit submission from inputs outside the grid', () => { const outerInput: HTMLInputElement = fixture.nativeElement.querySelector('#outerInput'); expect(pressEnter(outerInput).defaultPrevented).toBe(false); @@ -205,7 +247,7 @@ describe('IgxGrid - implicit form submission from grid components outside of the @Component({ template: ` -
+ @@ -218,8 +260,12 @@ describe('IgxGrid - implicit form submission from grid components outside of the +
DetailsContent
+ +
+
`, @@ -239,6 +285,7 @@ class GridInFormComponent { public grid!: IgxGridComponent; public data = DATA; + public submits = 0; } @Component({ diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html index 30bccbcc492..af95f530c3a 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.html @@ -7,7 +7,7 @@ tabindex="0" placeholder="Search" autocomplete="off" - (keydown.enter)="!$event.isComposing && $event.preventDefault()" + (keydown)="preventImplicitSubmission($event)" /> diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts index 901c7a05377..1894d4e892d 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts @@ -25,7 +25,7 @@ import { IgxExpansionPanelBodyComponent, IgxExpansionPanelComponent, IgxExpansio import { IDragBaseEventArgs, IDragGhostBaseEventArgs, IDragMoveEventArgs, IDropBaseEventArgs, IDropDroppedEventArgs, IgxDragDirective, IgxDragHandleDirective, IgxDropDirective } from 'igniteui-angular/directives'; import { IgxChipComponent } from 'igniteui-angular/chips'; import { IgxDropDownComponent, IgxDropDownItemComponent, IgxDropDownItemNavigationDirective, ISelectionEventArgs } from 'igniteui-angular/drop-down'; -import { AbsoluteScrollStrategy, AutoPositionStrategy, ColumnType, OverlaySettings, PositionSettings, ɵSize, SortingDirection, VerticalAlignment } from 'igniteui-angular/core'; +import { AbsoluteScrollStrategy, AutoPositionStrategy, ColumnType, OverlaySettings, PositionSettings, preventImplicitSubmission, ɵSize, SortingDirection, VerticalAlignment } from 'igniteui-angular/core'; import { IPivotAggregator, IPivotDimension, IPivotValue, PivotDimensionType, PivotGridType, PivotUtil } from 'igniteui-angular/grids/core'; import { Subscription } from 'rxjs'; @@ -77,6 +77,11 @@ export class IgxPivotDataSelectorComponent implements OnDestroy { private pivotConfigChangeSub?: Subscription; protected pipeRetrigger = 0; + /** + * @hidden @internal + */ + protected readonly preventImplicitSubmission = preventImplicitSubmission; + /** * Gets/sets whether the columns panel is expanded diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts index 3313cc762c4..bbd55e819d8 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.spec.ts @@ -50,9 +50,20 @@ describe("Pivot data selector #pivotGrid", () => { fixture.detectChanges(); const input = fixture.debugElement.query(By.directive(IgxInputDirective)).nativeElement as HTMLInputElement; - const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); - input.dispatchEvent(event); - expect(event.defaultPrevented).toBe(true); + const pressEnter = () => { + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + input.dispatchEvent(event); + return event.defaultPrevented; + }; + + expect(pressEnter()).toBe(false); + + const form = document.createElement('form'); + fixture.nativeElement.parentElement.appendChild(form); + form.appendChild(fixture.nativeElement); + expect(input.form).toBe(form); + expect(pressEnter()).toBe(true); + form.remove(); }); }); diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts index 855cabc7462..0e0fa782d90 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts @@ -32,6 +32,7 @@ describe('IgxQueryBuilder', () => { NoopAnimationsModule, IgxQueryBuilderComponent, IgxQueryBuilderSampleTestComponent, + IgxQueryBuilderInFormSampleTestComponent, IgxQueryBuilderCustomTemplateSampleTestComponent, IgxComboComponent ] @@ -1135,6 +1136,23 @@ describe('IgxQueryBuilder', () => { expect(groupButton.getAttribute('type')).toBe('button'); }); + it('Should prevent the implicit submission of a surrounding form on Enter in the value input.', async () => { + const formFix = TestBed.createComponent(IgxQueryBuilderInFormSampleTestComponent); + formFix.detectChanges(); + formFix.componentInstance.queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); + formFix.detectChanges(); + + QueryBuilderFunctions.clickQueryBuilderTreeExpressionChip(formFix, [0]); + await formFix.whenStable(); + formFix.detectChanges(); + + const input = QueryBuilderFunctions.getQueryBuilderValueInput(formFix)!.querySelector('input')!; + expect(input.form).toBe(formFix.nativeElement.querySelector('form')); + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, composed: true }); + input.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + }); + it('Should have disabled adding buttons when an expression is in edit mode.', async () => { queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree(); fix.detectChanges(); @@ -4294,6 +4312,26 @@ export class IgxQueryBuilderSampleTestComponent implements OnInit { } } +@Component({ + template: ` +
+ + + +
+ `, + standalone: true, + changeDetection: ChangeDetectionStrategy.Eager, + imports: [ + IgxQueryBuilderComponent + ] +}) +export class IgxQueryBuilderInFormSampleTestComponent { + @ViewChild(IgxQueryBuilderComponent) + public queryBuilder!: IgxQueryBuilderComponent; + public entities: Array = SampleEntities.map(a => ({ ...a })); +} + @Component({ template: ` diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts index c9fff3429e3..c2325dc1b23 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts @@ -11,7 +11,7 @@ import { ViewEncapsulation } from '@angular/core'; import { - Component, Input, ViewChild, OnDestroy, HostBinding + Component, Input, ViewChild, OnDestroy, HostBinding, HostListener } from '@angular/core'; import { Subject } from 'rxjs'; import { @@ -22,7 +22,8 @@ import { QueryBuilderResourceStringsEN, recreateTree, getCurrentResourceStrings, - onResourceChangeHandle + onResourceChangeHandle, + preventImplicitSubmission } from 'igniteui-angular/core'; import { IgxQueryBuilderTreeComponent } from './query-builder-tree.component'; import { IgxIconService } from 'igniteui-angular/icon'; @@ -61,6 +62,15 @@ export class IgxQueryBuilderComponent implements OnDestroy { @HostBinding('class.igx-query-builder') public cssClass = 'igx-query-builder'; + /** + * @hidden @internal + */ + @HostListener('keydown', ['$event']) + @HostListener('keypress', ['$event']) + public onKeyDown(event: KeyboardEvent): void { + preventImplicitSubmission(event); + } + /** * @hidden @internal */ From ebd36adc931578a197dbf4728fbcf4c66643e33c Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 10 Nov 2026 11:57:21 +0200 Subject: [PATCH 08/11] chore(*): update documentation comment --- projects/igniteui-angular/core/src/core/utils.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/utils.ts b/projects/igniteui-angular/core/src/core/utils.ts index 7e7068d35dd..cc494fb9532 100644 --- a/projects/igniteui-angular/core/src/core/utils.ts +++ b/projects/igniteui-angular/core/src/core/utils.ts @@ -489,12 +489,8 @@ const ACTIVATABLE_INPUT_TYPES = new Set(['button', 'submit', 'reset', 'image', ' * @internal * * Cancels the implicit submission of the surrounding form, which the browser performs as the default action - * of an Enter keydown in an `` (https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission). - * Browsers carry it out on the following `keypress`, which targets the focused element at that time, so handle both events - * where a widget (e.g. a picker selecting a date on Enter) may move the focus to a form input during the keydown. - * Only inputs which have a form owner and no activation behavior of their own are affected, - * so the native Enter behavior of any other element is preserved. - * The target is resolved through the composed path, as `event.target` is retargeted to the host for elements in a shadow root. + * of an Enter keydown in an ``. + * Only inputs which have a form owner and no activation behavior of their own are affected. */ export function preventImplicitSubmission(event: KeyboardEvent): void { const target = event.composedPath()[0]; From 02f501ffcaa32012b1084f757aaefd48e25204e8 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 10 Nov 2026 15:07:47 +0200 Subject: [PATCH 09/11] chore(*): address PR comments --- .../column-actions.component.ts | 3 - .../grid/src/grid-form-submission.spec.ts | 71 +++---------------- .../src/pivot-data-selector.component.ts | 3 - .../query-builder.component.spec.ts | 1 - .../query-builder/query-builder.component.ts | 18 ++--- 5 files changed, 17 insertions(+), 79 deletions(-) diff --git a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts index e2cac29e28a..510a1d059c8 100644 --- a/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts +++ b/projects/igniteui-angular/grids/core/src/column-actions/column-actions.component.ts @@ -45,9 +45,6 @@ let NEXT_ID = 0; export class IgxColumnActionsComponent implements DoCheck { private differs = inject(IterableDiffers); - /** - * @hidden @internal - */ protected readonly preventImplicitSubmission = preventImplicitSubmission; diff --git a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts index 2b91ab7f8d9..1b49b5da8bb 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts @@ -1,9 +1,9 @@ -import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, ViewChild } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { IgxGridComponent } from './grid.component'; -import { FilterMode, IgxAdvancedFilteringDialogComponent, IgxCellTemplateDirective, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnHidingDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; +import { FilterMode, IgxCellTemplateDirective, IgxColumnActionsComponent, IgxColumnComponent, IgxColumnHidingDirective, IgxGridToolbarActionsComponent, IgxGridToolbarComponent, IgxGridToolbarHidingComponent } from 'igniteui-angular/grids/core'; import { AbsoluteScrollStrategy, GlobalPositionStrategy } from 'igniteui-angular/core'; import { IgxInputDirective } from 'igniteui-angular/input-group'; import { IgxDatePickerComponent } from 'igniteui-angular/date-picker'; @@ -28,17 +28,6 @@ const pressEnter = (target: Element): KeyboardEvent => { return event; }; -const SHADOW_CONTROLS_TAG = 'igx-test-shadow-controls'; -if (!customElements.get(SHADOW_CONTROLS_TAG)) { - customElements.define(SHADOW_CONTROLS_TAG, class extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }).innerHTML = - 'Link'; - } - }); -} - const addAdvancedFilteringCondition = async (fixture: ComponentFixture): Promise => { QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0); await fixture.whenStable(); @@ -69,6 +58,8 @@ describe('IgxGrid - implicit form submission #grid', () => { }); it('should not render buttons which can become the default button of the form', () => { + // The add row snackbar is always rendered and was the default button of the form before the fix. + expect(grid.nativeElement.querySelector('igx-snackbar button')!.getAttribute('type')).toBe('button'); expect(grid.nativeElement.querySelectorAll(UNTYPED_BUTTONS).length).toBe(0); const defaultButton = form.querySelector('button:not([type]), button[type=submit], input[type=submit]'); expect(defaultButton!.id).toBe('submit'); @@ -122,18 +113,6 @@ describe('IgxGrid - implicit form submission #grid', () => { expect(pressEnter(input).defaultPrevented).toBe(true); }); - it('should prevent implicit submission on Enter in the advanced filtering dialog input', async () => { - grid.openAdvancedFilteringDialog(); - fixture.detectChanges(); - - const input = await addAdvancedFilteringCondition(fixture); - expect(input.form).toBe(form); - expect(pressEnter(input).defaultPrevented).toBe(true); - - grid.closeAdvancedFilteringDialog(false); - await fixture.whenStable(); - }); - it('should prevent implicit submission on Enter in the advanced filtering dialog opened with custom overlay settings', async () => { grid.openAdvancedFilteringDialog({ closeOnOutsideClick: false, @@ -183,32 +162,13 @@ describe('IgxGrid - implicit form submission #grid', () => { expect(fixture.componentInstance.submits).toBe(1); }); - it('should not prevent the native Enter activation of buttons inside the grid', () => { - const hidingButton: HTMLButtonElement = fixture.nativeElement.querySelector('igx-grid-toolbar-hiding button'); - expect(pressEnter(hidingButton).defaultPrevented).toBe(false); - }); - - it('should not prevent the native Enter behavior of controls inside a shadow root in the grid', () => { - const shadowRoot = fixture.nativeElement.querySelector(SHADOW_CONTROLS_TAG).shadowRoot as ShadowRoot; - expect(grid.nativeElement.contains(shadowRoot.host)).toBe(true); - - expect(pressEnter(shadowRoot.querySelector('button span')!).defaultPrevented).toBe(false); - expect(pressEnter(shadowRoot.querySelector('textarea')!).defaultPrevented).toBe(false); - expect(pressEnter(shadowRoot.querySelector('a')!).defaultPrevented).toBe(false); - }); - it('should not prevent the native Enter behavior of other elements inside the grid', () => { - const cell: HTMLElement = fixture.nativeElement.querySelector(SHADOW_CONTROLS_TAG).parentElement; - expect(cell.querySelector('input[type=file]')!.form).toBe(form); - - expect(pressEnter(cell.querySelector('summary')!).defaultPrevented).toBe(false); - expect(pressEnter(cell.querySelector('input[type=file]')!).defaultPrevented).toBe(false); - expect(pressEnter(cell.querySelector('input[type=color]')!).defaultPrevented).toBe(false); - }); + const fileInput = grid.nativeElement.querySelector('input[type=file]') as HTMLInputElement; + expect(fileInput.form).toBe(form); - it('should not affect implicit submission from inputs outside the grid', () => { - const outerInput: HTMLInputElement = fixture.nativeElement.querySelector('#outerInput'); - expect(pressEnter(outerInput).defaultPrevented).toBe(false); + expect(pressEnter(grid.nativeElement.querySelector('summary')!).defaultPrevented).toBe(false); + expect(pressEnter(fileInput).defaultPrevented).toBe(false); + expect(pressEnter(grid.nativeElement.querySelector('input[type=color]')!).defaultPrevented).toBe(false); }); }); @@ -236,13 +196,6 @@ describe('IgxGrid - implicit form submission from grid components outside of the expect(input.form).toBe(form); expect(pressEnter(input).defaultPrevented).toBe(true); }); - - it('should prevent implicit submission on Enter in the standalone advanced filtering dialog input', async () => { - const input = await addAdvancedFilteringCondition(fixture); - expect(fixture.componentInstance.grid.nativeElement.contains(input)).toBe(false); - expect(input.form).toBe(form); - expect(pressEnter(input).defaultPrevented).toBe(true); - }); }); @Component({ @@ -259,7 +212,6 @@ describe('IgxGrid - implicit form submission from grid components outside of the -
DetailsContent
@@ -270,7 +222,6 @@ describe('IgxGrid - implicit form submission from grid components outside of the `, changeDetection: ChangeDetectionStrategy.Eager, - schemas: [CUSTOM_ELEMENTS_SCHEMA], imports: [ IgxGridComponent, IgxColumnComponent, @@ -297,7 +248,6 @@ class GridInFormComponent { - `, changeDetection: ChangeDetectionStrategy.Eager, @@ -305,8 +255,7 @@ class GridInFormComponent { IgxGridComponent, IgxColumnComponent, IgxColumnActionsComponent, - IgxColumnHidingDirective, - IgxAdvancedFilteringDialogComponent + IgxColumnHidingDirective ] }) class GridComponentsInFormComponent { diff --git a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts index 1894d4e892d..10fbd441074 100644 --- a/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts +++ b/projects/igniteui-angular/grids/pivot-grid/src/pivot-data-selector.component.ts @@ -77,9 +77,6 @@ export class IgxPivotDataSelectorComponent implements OnDestroy { private pivotConfigChangeSub?: Subscription; protected pipeRetrigger = 0; - /** - * @hidden @internal - */ protected readonly preventImplicitSubmission = preventImplicitSubmission; diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts index 56af6a50994..dc70950d545 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.spec.ts @@ -4452,7 +4452,6 @@ export class IgxQueryBuilderSampleTestComponent implements OnInit { `, - standalone: true, changeDetection: ChangeDetectionStrategy.Eager, imports: [ IgxQueryBuilderComponent diff --git a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts index c2325dc1b23..f8f1ce6acee 100644 --- a/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts +++ b/projects/igniteui-angular/query-builder/src/query-builder/query-builder.component.ts @@ -11,7 +11,7 @@ import { ViewEncapsulation } from '@angular/core'; import { - Component, Input, ViewChild, OnDestroy, HostBinding, HostListener + Component, Input, ViewChild, OnDestroy, HostBinding } from '@angular/core'; import { Subject } from 'rxjs'; import { @@ -51,26 +51,22 @@ import { IgxQueryBuilderHeaderComponent } from './query-builder-header.component styleUrls: ['./query-builder.component.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.Eager, - imports: [IgxQueryBuilderTreeComponent] + imports: [IgxQueryBuilderTreeComponent], + host: { + '(keydown)': 'preventImplicitSubmission($event)' + } }) export class IgxQueryBuilderComponent implements OnDestroy { protected iconService = inject(IgxIconService); + protected readonly preventImplicitSubmission = preventImplicitSubmission; + /** * @hidden @internal */ @HostBinding('class.igx-query-builder') public cssClass = 'igx-query-builder'; - /** - * @hidden @internal - */ - @HostListener('keydown', ['$event']) - @HostListener('keypress', ['$event']) - public onKeyDown(event: KeyboardEvent): void { - preventImplicitSubmission(event); - } - /** * @hidden @internal */ From 4cd6975371d76df78a8968d06256b1e0051a2362 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 10 Nov 2026 15:53:02 +0200 Subject: [PATCH 10/11] chore(*): test improvements --- .../core/src/core/utils.spec.ts | 58 ++++++++++++++++++- .../grid/src/grid-form-submission.spec.ts | 23 +++++++- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/projects/igniteui-angular/core/src/core/utils.spec.ts b/projects/igniteui-angular/core/src/core/utils.spec.ts index 131b514e7cc..b8214ba2ad0 100644 --- a/projects/igniteui-angular/core/src/core/utils.spec.ts +++ b/projects/igniteui-angular/core/src/core/utils.spec.ts @@ -1,7 +1,7 @@ import { SampleTestData } from 'igniteui-angular/test-utils/sample-test-data.spec'; import { cloneValue, cloneValueCached, cloneHierarchicalArray, compareMaps, getComponentCssSizeVar, - intoChunks, isDate, isLeftToRight, isObject, showMessage, uniqueDates } from './utils'; -import { describe, it, expect } from 'vitest'; + intoChunks, isDate, isLeftToRight, isObject, preventImplicitSubmission, showMessage, uniqueDates } from './utils'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; describe('Utils', () => { const complexObject = { @@ -430,4 +430,58 @@ describe('Utils', () => { expect(isDate(variable)).toBeFalsy(); }); }); + + describe('Utils - preventImplicitSubmission() unit tests', () => { + let host: HTMLDivElement; + + const dispatchKeydown = (target: Element, init: KeyboardEventInit = {}): boolean => { + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true, composed: true, ...init }); + target.dispatchEvent(event); + return event.defaultPrevented; + }; + + beforeEach(() => { + host = document.createElement('div'); + host.addEventListener('keydown', preventImplicitSubmission); + document.body.appendChild(host); + }); + + afterEach(() => { + host.remove(); + }); + + it('should prevent Enter only on a form-owned input which would submit the form', () => { + host.innerHTML = '
'; + const [textInput, fileInput] = Array.from(host.querySelectorAll('form input')); + + expect(dispatchKeydown(textInput)).toBe(true); + expect(dispatchKeydown(fileInput)).toBe(false); + expect(dispatchKeydown(host.querySelector('#noForm')!)).toBe(false); + }); + + it('should not prevent other keys than Enter', () => { + host.innerHTML = '
'; + const input = host.querySelector('input')!; + + expect(dispatchKeydown(input, { key: 'a' })).toBe(false); + expect(dispatchKeydown(input, { key: ' ' })).toBe(false); + }); + + it('should not prevent Enter while an IME composition is in progress', () => { + host.innerHTML = '
'; + + expect(dispatchKeydown(host.querySelector('input')!, { isComposing: true })).toBe(false); + }); + + it('should resolve the target of the event through its composed path', () => { + const shadowHost = document.createElement('div'); + host.appendChild(shadowHost); + const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); + shadowRoot.innerHTML = '
'; + const input = shadowRoot.querySelector('input')!; + + expect(input.form).toBe(shadowRoot.querySelector('form')); + expect(dispatchKeydown(input)).toBe(true); + }); + }); }); diff --git a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts index 1b49b5da8bb..36de95a2b50 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts @@ -70,9 +70,26 @@ describe('IgxGrid - implicit form submission #grid', () => { await fixture.whenStable(); fixture.detectChanges(); - const input = fixture.debugElement.query(By.css(FILTER_UI_ROW)).query(By.directive(IgxInputDirective)); - expect(input.nativeElement.form).toBe(form); - expect(pressEnter(input.nativeElement).defaultPrevented).toBe(true); + const input: HTMLInputElement = fixture.debugElement.query(By.css(FILTER_UI_ROW)).query(By.directive(IgxInputDirective)).nativeElement; + expect(input.form).toBe(form); + expect(pressEnter(input).defaultPrevented).toBe(true); + + await userEvent.click(input); + for (const key of 'Chai') { + await userEvent.keyboard(key); + await fixture.whenStable(); + await new Promise(resolve => requestAnimationFrame(resolve)); + fixture.detectChanges(); + } + expect(input.value).toBe('Chai'); + + await userEvent.keyboard('{Enter}'); + await fixture.whenStable(); + await new Promise(resolve => requestAnimationFrame(resolve)); + fixture.detectChanges(); + + expect(grid.filteredData?.map(r => r.ProductName)).toEqual(['Chai']); + expect(fixture.componentInstance.submits).toBe(0); }); it('should prevent implicit submission on Enter in a cell editor', () => { From db0e3750a00b0f8cde54d7e7df8fc50182ee2065 Mon Sep 17 00:00:00 2001 From: igdmdimitrov Date: Tue, 10 Nov 2026 16:56:29 +0200 Subject: [PATCH 11/11] chore(*): moved import where it belongs --- .../igniteui-angular/grids/grid/src/grid-base.directive.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts index eb5e6204232..40c06f19ee8 100644 --- a/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts +++ b/projects/igniteui-angular/grids/grid/src/grid-base.directive.ts @@ -103,7 +103,8 @@ import { runAfterRenderOnce, GridStyleCSSProperty, I18N_FORMATTER, - IgxStylesRegistrar + IgxStylesRegistrar, + preventImplicitSubmission } from 'igniteui-angular/core'; import { IgcTrialWatermark } from 'igniteui-trial-watermark'; import { @@ -258,7 +259,6 @@ import { WatchChanges } from 'igniteui-angular/grids/core'; import { getCurrentI18n, getNumberFormatter, IResourceChangeEventArgs, } from 'igniteui-i18n-core'; -import { preventImplicitSubmission } from 'igniteui-angular/core'; import { GRID_BASE_CSS } from './grid-base.styles'; /**