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 = + '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 @@
@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 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();