diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7c951577823..d4b1ea56fcb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ 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 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.
- `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.
- `IgxCarouselComponent`
@@ -76,6 +81,7 @@ All notable changes for each version of this project will be documented in this
- `IgxOverlayService`
- Destroying a view while an overlay component attached through a `ViewContainerRef` is open (e.g. an open date picker calendar or grid filtering dialog) no longer leaves the parent components undestroyed.
+
## 22.2.1
### New Features
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 f111f4e0953..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,6 +14,7 @@
}
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 455949c93ab..5b07cf67050 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,6 +141,8 @@ 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 bfaf59cbae1..ec2091ddc93 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,17 @@ 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();
+ }
+ });
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..6e6fc1f66e5
--- /dev/null
+++ b/projects/igniteui-angular/grids/grid/src/grid-form-submission.spec.ts
@@ -0,0 +1,270 @@
+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, 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';
+import { QueryBuilderFunctions } from '../../../query-builder/src/query-builder/query-builder-functions.spec';
+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 }
+];
+
+const pressEnter = (target: Element): KeyboardEvent => {
+ 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 =
+ 'Click Link ';
+ }
+ });
+}
+
+const addAdvancedFilteringCondition = async (fixture: ComponentFixture): Promise => {
+ QueryBuilderFunctions.clickQueryBuilderInitialAddConditionBtn(fixture, 0);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ await QueryBuilderFunctions.selectColumnInEditModeExpression(fixture, 1); // 'ProductName'
+ await QueryBuilderFunctions.selectOperatorInEditModeExpression(fixture, 2); // 'Starts With'
+ return QueryBuilderFunctions.getQueryBuilderValueInput(fixture)!.querySelector('input')!;
+};
+
+describe('IgxGrid - implicit form submission #grid', () => {
+ let fixture: ComponentFixture;
+ let grid: IgxGridComponent;
+ let form: HTMLFormElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule, GridInFormComponent]
+ }).compileComponents();
+ });
+
+ beforeEach(async () => {
+ fixture = TestBed.createComponent(GridInFormComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ 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);
+ 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', async () => {
+ GridFunctions.clickFilterCellChipUI(fixture, 'ProductName');
+ 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);
+ });
+
+ 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.nativeElement.querySelector('.igx-grid__td--editing input')!;
+ expect(input.form).toBe(form);
+ expect(pressEnter(input).defaultPrevented).toBe(true);
+ });
+
+ it('should prevent implicit submission on Enter in the toolbar column hiding search input', async () => {
+ const hidingButton: HTMLButtonElement = fixture.nativeElement.querySelector('igx-grid-toolbar-hiding button');
+ hidingButton.click();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const input: HTMLInputElement = grid.nativeElement.querySelector('igx-column-actions input')!;
+ expect(input.form).toBe(form);
+ expect(pressEnter(input).defaultPrevented).toBe(true);
+ });
+
+ it('should prevent implicit submission on Enter in the excel style filtering custom dialog input', async () => {
+ grid.filterMode = FilterMode.excelStyleFilter;
+ fixture.detectChanges();
+
+ await GridFunctions.clickExcelFilterIconFromCode(fixture, grid, 'ProductName');
+ GridFunctions.clickExcelFilterCascadeButton(fixture);
+ await fixture.whenStable();
+ fixture.detectChanges();
+ GridFunctions.clickOperatorFromCascadeMenu(fixture, 0);
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const input = GridFunctions.getExcelFilteringInput(fixture, 0);
+ expect(input.form).toBe(form);
+ 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,
+ modal: false,
+ positionStrategy: new GlobalPositionStrategy(),
+ scrollStrategy: new AbsoluteScrollStrategy()
+ });
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const input = await addAdvancedFilteringCondition(fixture);
+ expect(grid.nativeElement.contains(input)).toBe(false);
+ expect(input.form).toBe(form);
+ expect(pressEnter(input).defaultPrevented).toBe(true);
+
+ grid.closeAdvancedFilteringDialog(false);
+ await fixture.whenStable();
+ });
+
+ 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 affect implicit submission from inputs outside the grid', () => {
+ const outerInput: HTMLInputElement = fixture.nativeElement.querySelector('#outerInput');
+ expect(pressEnter(outerInput).defaultPrevented).toBe(false);
+ });
+});
+
+describe('IgxGrid - implicit form submission from grid components outside of the grid #grid', () => {
+ let fixture: ComponentFixture;
+ let form: HTMLFormElement;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [NoopAnimationsModule, GridComponentsInFormComponent]
+ }).compileComponents();
+ });
+
+ beforeEach(async () => {
+ fixture = TestBed.createComponent(GridComponentsInFormComponent);
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+ form = fixture.nativeElement.querySelector('form');
+ });
+
+ it('should prevent implicit submission on Enter in the standalone column actions filter input', () => {
+ const input: HTMLInputElement = fixture.nativeElement.querySelector('#standaloneActions input');
+ expect(fixture.componentInstance.grid.nativeElement.contains(input)).toBe(false);
+ 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({
+ template: `
+ `,
+ changeDetection: ChangeDetectionStrategy.Eager,
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+ imports: [
+ IgxGridComponent,
+ IgxColumnComponent,
+ IgxCellTemplateDirective,
+ IgxGridToolbarComponent,
+ IgxGridToolbarActionsComponent,
+ IgxGridToolbarHidingComponent
+ ]
+})
+class GridInFormComponent {
+ @ViewChild('grid', { static: true })
+ public grid!: IgxGridComponent;
+
+ public data = DATA;
+}
+
+@Component({
+ template: `
+ `,
+ 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..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,6 +7,7 @@
tabindex="0"
placeholder="Search"
autocomplete="off"
+ (keydown.enter)="!$event.isComposing && $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 81d929cb1e0..3313cc762c4 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
@@ -44,6 +44,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).toBe(true);
+ });
});
describe("Pivot data selector integration #pivotGrid", () => {
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 8ff061384b9..45c69a665be 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
@@ -328,6 +328,7 @@
{
expect(QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0]), 'actions container is visible').toBeNull();
});
+ it('Should render the add expression and group buttons with type="button" to prevent form submission.', async () => {
+ queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree();
+ fix.detectChanges();
+
+ UIInteractions.hoverElement(QueryBuilderFunctions.getQueryBuilderTreeItem(fix, [0]) as HTMLElement);
+ await fix.whenStable();
+ fix.detectChanges();
+
+ const addButton = QueryBuilderFunctions.getQueryBuilderTreeExpressionActionsContainer(fix, [0]).querySelector('button');
+ const groupButton = fix.nativeElement.querySelector('button.igx-filter-tree__button--and, button.igx-filter-tree__button--or');
+ expect(addButton.getAttribute('type')).toBe('button');
+ expect(groupButton.getAttribute('type')).toBe('button');
+ });
+
it('Should have disabled adding buttons when an expression is in edit mode.', async () => {
queryBuilder.expressionTree = QueryBuilderFunctions.generateExpressionTree();
fix.detectChanges();
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) {
-
+
{{ 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 fd2c469a0d6..334015f57c6 100644
--- a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts
+++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts
@@ -98,6 +98,17 @@ describe('IgxSnackbar', () => {
expect(snackbar.clicked.emit).toHaveBeenCalledWith(snackbar);
});
+ it('should render the action button with type="button" to prevent form submission', async () => {
+ snackbar.actionText = 'undo';
+ snackbar.displayTime = 100;
+
+ snackbar.open();
+ 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', async () => {
vi.spyOn(snackbar.opening, 'emit');
snackbar.open();