From 68bae80a51dae70c4cd7e598c1f2eabb025f173e Mon Sep 17 00:00:00 2001 From: Sean Perkins Date: Fri, 23 Sep 2022 13:26:21 -0400 Subject: [PATCH] feat(textarea): ionChange will only emit from user committed changes (#25953) --- BREAKING.md | 12 +++ .../text-value-accessor.ts | 5 +- angular/src/directives/proxies.ts | 12 ++- angular/test/base/e2e/src/textarea.spec.ts | 18 ++++ .../test/base/src/app/app-routing.module.ts | 1 + .../base/src/app/form/form.component.html | 2 +- .../app/textarea/textarea-routing.module.ts | 16 ++++ .../src/app/textarea/textarea.component.html | 16 ++++ .../src/app/textarea/textarea.component.ts | 17 ++++ .../base/src/app/textarea/textarea.module.ts | 21 +++++ core/api.txt | 2 +- core/src/components.d.ts | 22 ++--- core/src/components/input/input.tsx | 3 +- .../textarea/test/textarea-events.e2e.ts | 93 +++++++++++++++++++ core/src/components/textarea/textarea.tsx | 73 ++++++++++++--- 15 files changed, 280 insertions(+), 33 deletions(-) create mode 100644 angular/test/base/e2e/src/textarea.spec.ts create mode 100644 angular/test/base/src/app/textarea/textarea-routing.module.ts create mode 100644 angular/test/base/src/app/textarea/textarea.component.html create mode 100644 angular/test/base/src/app/textarea/textarea.component.ts create mode 100644 angular/test/base/src/app/textarea/textarea.module.ts create mode 100644 core/src/components/textarea/test/textarea-events.e2e.ts diff --git a/BREAKING.md b/BREAKING.md index 7104eb17ba..8735baa995 100644 --- a/BREAKING.md +++ b/BREAKING.md @@ -21,6 +21,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver - [Range](#version-7x-range) - [Segment](#version-7x-segment) - [Slides](#version-7x-slides) + - [Textarea](#version-7x-textarea) - [Virtual Scroll](#version-7x-virtual-scroll) - [Utilities](#version-7x-utilities) - [hidden attribute](#version-7x-hidden-attribute) @@ -109,6 +110,17 @@ Developers using these components will need to migrate to using Swiper.js direct - [React](https://ionicframework.com/docs/react/slides) - [Vue](https://ionicframework.com/docs/vue/slides) +

Textarea

+ +- `ionChange` is no longer emitted when the `value` of `ion-textarea` is modified externally. `ionChange` is only emitted from user committed changes, such as typing in the textarea and the textarea losing focus. + + - If your application requires immediate feedback based on the user typing actively in the textarea, consider migrating your event listeners to using `ionInput` instead. + +- The `debounce` property has been updated to control the timing in milliseconds to delay the event emission of the `ionInput` event after each keystroke. Previously it would delay the event emission of `ionChange`. + +- `ionInput` dispatches an event detail of `null` when the textarea is cleared as a result of `clear-on-edit="true"`. + +

Virtual Scroll

`ion-virtual-scroll` has been removed from Ionic. diff --git a/angular/src/directives/control-value-accessors/text-value-accessor.ts b/angular/src/directives/control-value-accessors/text-value-accessor.ts index abcc581dea..772bc65eb7 100644 --- a/angular/src/directives/control-value-accessors/text-value-accessor.ts +++ b/angular/src/directives/control-value-accessors/text-value-accessor.ts @@ -5,7 +5,7 @@ import { ValueAccessor } from './value-accessor'; @Directive({ /* tslint:disable-next-line:directive-selector */ - selector: 'ion-textarea,ion-searchbar', + selector: 'ion-searchbar', providers: [ { provide: NG_VALUE_ACCESSOR, @@ -26,7 +26,7 @@ export class TextValueAccessorDirective extends ValueAccessor { } @Directive({ - selector: 'ion-input:not([type=number])', + selector: 'ion-input:not([type=number]),ion-textarea', providers: [ { provide: NG_VALUE_ACCESSOR, @@ -35,6 +35,7 @@ export class TextValueAccessorDirective extends ValueAccessor { }, ], }) +// TODO rename this value accessor to `TextValueAccessorDirective` when search-bar is updated export class InputValueAccessorDirective extends ValueAccessor { constructor(injector: Injector, el: ElementRef) { super(injector, el); diff --git a/angular/src/directives/proxies.ts b/angular/src/directives/proxies.ts index cb4e3c71c1..e76bf3a868 100644 --- a/angular/src/directives/proxies.ts +++ b/angular/src/directives/proxies.ts @@ -1816,13 +1816,19 @@ export class IonText { import type { TextareaChangeEventDetail as ITextareaTextareaChangeEventDetail } from '@ionic/core'; export declare interface IonTextarea extends Components.IonTextarea { /** - * Emitted when the input value has changed. + * The `ionChange` event is fired for `` elements when the user +modifies the element's value. Unlike the `ionInput` event, the `ionChange` +event is not necessarily fired for each alteration to an element's value. + +The `ionChange` event is fired when the element loses focus after its value +has been modified. */ ionChange: EventEmitter>; /** - * Emitted when a keyboard input occurred. + * Ths `ionInput` event fires when the `value` of an `` element +has been changed. */ - ionInput: EventEmitter>; + ionInput: EventEmitter>; /** * Emitted when the input loses focus. */ diff --git a/angular/test/base/e2e/src/textarea.spec.ts b/angular/test/base/e2e/src/textarea.spec.ts new file mode 100644 index 0000000000..3db090e2e6 --- /dev/null +++ b/angular/test/base/e2e/src/textarea.spec.ts @@ -0,0 +1,18 @@ +describe('Textarea', () => { + beforeEach(() => cy.visit('/textarea')); + + it('should become valid', () => { + cy.get('#status').should('have.text', 'INVALID'); + + cy.get('ion-textarea').type('hello'); + + cy.get('#status').should('have.text', 'VALID'); + }); + + it('should update the form control value when typing', () => { + cy.get('#value').contains(`"textarea": ""`); + cy.get('ion-textarea').type('hello'); + + cy.get('#value').contains(`"textarea": "hello"`); + }); +}); diff --git a/angular/test/base/src/app/app-routing.module.ts b/angular/test/base/src/app/app-routing.module.ts index c1988c52c0..279d6b3cd7 100644 --- a/angular/test/base/src/app/app-routing.module.ts +++ b/angular/test/base/src/app/app-routing.module.ts @@ -25,6 +25,7 @@ const routes: Routes = [ { path: 'accordions', component: AccordionComponent }, { path: 'alerts', component: AlertComponent }, { path: 'inputs', component: InputsComponent }, + { path: 'textarea', loadChildren: () => import('./textarea/textarea.module').then(m => m.TextareaModule) }, { path: 'form', component: FormComponent }, { path: 'modals', component: ModalComponent }, { path: 'modal-inline', loadChildren: () => import('./modal-inline').then(m => m.ModalInlineModule) }, diff --git a/angular/test/base/src/app/form/form.component.html b/angular/test/base/src/app/form/form.component.html index 34740df87f..c9426e5fca 100644 --- a/angular/test/base/src/app/form/form.component.html +++ b/angular/test/base/src/app/form/form.component.html @@ -61,7 +61,7 @@ Form Status: {{ profileForm.status }}

- Form Status: {{ profileForm.value | json }} + Form value: {{ profileForm.value | json }}

Form Submit: {{submitted}} diff --git a/angular/test/base/src/app/textarea/textarea-routing.module.ts b/angular/test/base/src/app/textarea/textarea-routing.module.ts new file mode 100644 index 0000000000..379ce41aa0 --- /dev/null +++ b/angular/test/base/src/app/textarea/textarea-routing.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from "@angular/core"; +import { RouterModule } from "@angular/router"; +import { TextareaComponent } from "./textarea.component"; + +@NgModule({ + imports: [ + RouterModule.forChild([ + { + path: '', + component: TextareaComponent + } + ]) + ], + exports: [RouterModule] +}) +export class TextareaRoutingModule { } diff --git a/angular/test/base/src/app/textarea/textarea.component.html b/angular/test/base/src/app/textarea/textarea.component.html new file mode 100644 index 0000000000..732e018dcb --- /dev/null +++ b/angular/test/base/src/app/textarea/textarea.component.html @@ -0,0 +1,16 @@ + +

+ + + Textarea + + + +
+

+ Form status: {{ form.status }} +

+

+ Form value: {{ form.value | json }} +

+ diff --git a/angular/test/base/src/app/textarea/textarea.component.ts b/angular/test/base/src/app/textarea/textarea.component.ts new file mode 100644 index 0000000000..546c336ddd --- /dev/null +++ b/angular/test/base/src/app/textarea/textarea.component.ts @@ -0,0 +1,17 @@ + +import { Component } from '@angular/core'; +import { FormBuilder, Validators } from '@angular/forms'; + +@Component({ + selector: 'app-textarea', + templateUrl: 'textarea.component.html', +}) +export class TextareaComponent { + + form = this.fb.group({ + textarea: ['', Validators.required] + }) + + constructor(private fb: FormBuilder) { } + +} diff --git a/angular/test/base/src/app/textarea/textarea.module.ts b/angular/test/base/src/app/textarea/textarea.module.ts new file mode 100644 index 0000000000..3ba3d9294d --- /dev/null +++ b/angular/test/base/src/app/textarea/textarea.module.ts @@ -0,0 +1,21 @@ +import { CommonModule } from "@angular/common"; +import { NgModule } from "@angular/core"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { IonicModule } from "@ionic/angular"; + +import { TextareaRoutingModule } from "./textarea-routing.module"; +import { TextareaComponent } from "./textarea.component"; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + IonicModule, + TextareaRoutingModule + ], + declarations: [ + TextareaComponent + ] +}) +export class TextareaModule { } diff --git a/core/api.txt b/core/api.txt index 256ba9ffa7..b4ccf42d1a 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1307,7 +1307,7 @@ ion-textarea,method,setFocus,setFocus() => Promise ion-textarea,event,ionBlur,FocusEvent,true ion-textarea,event,ionChange,TextareaChangeEventDetail,true ion-textarea,event,ionFocus,FocusEvent,true -ion-textarea,event,ionInput,InputEvent,true +ion-textarea,event,ionInput,InputEvent | null,true ion-textarea,css-prop,--background ion-textarea,css-prop,--border-radius ion-textarea,css-prop,--color diff --git a/core/src/components.d.ts b/core/src/components.d.ts index 1250e343fa..78a4cbbe05 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -2719,7 +2719,7 @@ export namespace Components { */ "autofocus": boolean; /** - * If `true`, the value will be cleared after focus upon edit. Defaults to `true` when `type` is `"password"`, `false` for all other types. + * If `true`, the value will be cleared after focus upon edit. */ "clearOnEdit": boolean; /** @@ -2731,7 +2731,7 @@ export namespace Components { */ "cols"?: number; /** - * Set the amount of time, in milliseconds, to wait to trigger the `ionChange` event after each keystroke. This also impacts form bindings such as `ngModel` or `v-model`. + * Set the amount of time, in milliseconds, to wait to trigger the `ionInput` event after each keystroke. */ "debounce": number; /** @@ -2751,11 +2751,11 @@ export namespace Components { */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the maximum number of characters that the user can enter. + * This attribute specifies the maximum number of characters that the user can enter. */ "maxlength"?: number; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the minimum number of characters that the user can enter. + * This attribute specifies the minimum number of characters that the user can enter. */ "minlength"?: number; /** @@ -6518,7 +6518,7 @@ declare namespace LocalJSX { */ "autofocus"?: boolean; /** - * If `true`, the value will be cleared after focus upon edit. Defaults to `true` when `type` is `"password"`, `false` for all other types. + * If `true`, the value will be cleared after focus upon edit. */ "clearOnEdit"?: boolean; /** @@ -6530,7 +6530,7 @@ declare namespace LocalJSX { */ "cols"?: number; /** - * Set the amount of time, in milliseconds, to wait to trigger the `ionChange` event after each keystroke. This also impacts form bindings such as `ngModel` or `v-model`. + * Set the amount of time, in milliseconds, to wait to trigger the `ionInput` event after each keystroke. */ "debounce"?: number; /** @@ -6546,11 +6546,11 @@ declare namespace LocalJSX { */ "inputmode"?: 'none' | 'text' | 'tel' | 'url' | 'email' | 'numeric' | 'decimal' | 'search'; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the maximum number of characters that the user can enter. + * This attribute specifies the maximum number of characters that the user can enter. */ "maxlength"?: number; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the minimum number of characters that the user can enter. + * This attribute specifies the minimum number of characters that the user can enter. */ "minlength"?: number; /** @@ -6566,7 +6566,7 @@ declare namespace LocalJSX { */ "onIonBlur"?: (event: IonTextareaCustomEvent) => void; /** - * Emitted when the input value has changed. + * The `ionChange` event is fired for `` elements when the user modifies the element's value. Unlike the `ionInput` event, the `ionChange` event is not necessarily fired for each alteration to an element's value. The `ionChange` event is fired when the element loses focus after its value has been modified. */ "onIonChange"?: (event: IonTextareaCustomEvent) => void; /** @@ -6574,9 +6574,9 @@ declare namespace LocalJSX { */ "onIonFocus"?: (event: IonTextareaCustomEvent) => void; /** - * Emitted when a keyboard input occurred. + * Ths `ionInput` event fires when the `value` of an `` element has been changed. */ - "onIonInput"?: (event: IonTextareaCustomEvent) => void; + "onIonInput"?: (event: IonTextareaCustomEvent) => void; /** * Emitted when the styles change. */ diff --git a/core/src/components/input/input.tsx b/core/src/components/input/input.tsx index c465c4969e..06edef738d 100644 --- a/core/src/components/input/input.tsx +++ b/core/src/components/input/input.tsx @@ -344,6 +344,7 @@ export class Input implements ComponentInterface { */ private emitValueChange() { const { value } = this; + // Checks for both null and undefined values const newValue = value == null ? value : value.toString(); this.ionChange.emit({ value: newValue }); } @@ -368,7 +369,7 @@ export class Input implements ComponentInterface { }); } - private onInput = (ev: Event) => { + private onInput = (ev: InputEvent | Event) => { const input = ev.target as HTMLInputElement | null; if (input) { this.value = input.value || ''; diff --git a/core/src/components/textarea/test/textarea-events.e2e.ts b/core/src/components/textarea/test/textarea-events.e2e.ts new file mode 100644 index 0000000000..fefb472031 --- /dev/null +++ b/core/src/components/textarea/test/textarea-events.e2e.ts @@ -0,0 +1,93 @@ +import { expect } from '@playwright/test'; +import { test } from '@utils/test/playwright'; + +test.describe('textarea: events: ionChange', () => { + test.beforeEach(({ skip }) => { + skip.rtl(); + }); + + test.describe('when the textarea is blurred', () => { + test('should emit if the value has changed', async ({ page }) => { + await page.setContent(``); + + const nativeTextarea = page.locator('ion-textarea textarea'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await nativeTextarea.type('new value', { delay: 100 }); + // Value change is not emitted until the control is blurred. + await nativeTextarea.evaluate((e) => e.blur()); + + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 'new value' }); + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + + test('should emit if the textarea is cleared with an initial value', async ({ page }) => { + await page.setContent(``); + + const textarea = page.locator('ion-textarea'); + const nativeTextarea = textarea.locator('textarea'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await nativeTextarea.type('new value'); + + await nativeTextarea.evaluate((e) => e.blur()); + + await ionChangeSpy.next(); + + expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 'new value' }); + expect(ionChangeSpy).toHaveReceivedEventTimes(1); + }); + + test('should not emit if the value is set programmatically', async ({ page }) => { + await page.setContent(``); + + const textarea = page.locator('ion-textarea'); + const ionChangeSpy = await page.spyOnEvent('ionChange'); + + await textarea.evaluate((el: HTMLIonTextareaElement) => { + el.value = 'new value'; + }); + + await page.waitForChanges(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + + // Update the value again to make sure it doesn't emit a second time + await textarea.evaluate((el: HTMLIonTextareaElement) => { + el.value = 'new value 2'; + }); + + await page.waitForChanges(); + + expect(ionChangeSpy).toHaveReceivedEventTimes(0); + }); + }); +}); + +test.describe('textarea: events: ionInput', () => { + test('should emit when the user types', async ({ page }) => { + await page.setContent(``); + + const ionInputSpy = await page.spyOnEvent('ionInput'); + + const nativeTextarea = page.locator('ion-textarea textarea'); + await nativeTextarea.type('new value', { delay: 100 }); + + expect(ionInputSpy).toHaveReceivedEventDetail({ isTrusted: true }); + }); + + test('should emit when the textarea is cleared on edit', async ({ page }) => { + await page.setContent(``); + + const ionInputSpy = await page.spyOnEvent('ionInput'); + const textarea = page.locator('ion-textarea'); + + await textarea.click(); + await textarea.press('Backspace'); + + expect(ionInputSpy).toHaveReceivedEventTimes(1); + expect(ionInputSpy).toHaveReceivedEventDetail(null); + }); +}); diff --git a/core/src/components/textarea/textarea.tsx b/core/src/components/textarea/textarea.tsx index 95a53d7a54..3252a25910 100644 --- a/core/src/components/textarea/textarea.tsx +++ b/core/src/components/textarea/textarea.tsx @@ -21,9 +21,13 @@ import { createColorClasses } from '../../utils/theme'; export class Textarea implements ComponentInterface { private nativeInput?: HTMLTextAreaElement; private inputId = `ion-textarea-${textareaIds++}`; - private didBlurAfterEdit = false; + private didBlurAfterEdit = this.hasValue(); private textareaWrapper?: HTMLElement; private inheritedAttributes: Attributes = {}; + /** + * The value of the input when the textarea is focused. + */ + private focusedValue?: string | null; @Element() el!: HTMLElement; @@ -48,18 +52,18 @@ export class Textarea implements ComponentInterface { @Prop() autofocus = false; /** - * If `true`, the value will be cleared after focus upon edit. Defaults to `true` when `type` is `"password"`, `false` for all other types. + * If `true`, the value will be cleared after focus upon edit. */ - @Prop({ mutable: true }) clearOnEdit = false; + @Prop() clearOnEdit = false; /** - * Set the amount of time, in milliseconds, to wait to trigger the `ionChange` event after each keystroke. This also impacts form bindings such as `ngModel` or `v-model`. + * Set the amount of time, in milliseconds, to wait to trigger the `ionInput` event after each keystroke. */ @Prop() debounce = 0; @Watch('debounce') protected debounceChanged() { - this.ionChange = debounceEvent(this.ionChange, this.debounce); + this.ionInput = debounceEvent(this.ionInput, this.debounce); } /** @@ -87,12 +91,12 @@ export class Textarea implements ComponentInterface { @Prop() enterkeyhint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send'; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the maximum number of characters that the user can enter. + * This attribute specifies the maximum number of characters that the user can enter. */ @Prop() maxlength?: number; /** - * If the value of the type attribute is `text`, `email`, `search`, `password`, `tel`, or `url`, this attribute specifies the minimum number of characters that the user can enter. + * This attribute specifies the minimum number of characters that the user can enter. */ @Prop() minlength?: number; @@ -159,18 +163,23 @@ export class Textarea implements ComponentInterface { } this.runAutoGrow(); this.emitStyle(); - this.ionChange.emit({ value }); } /** - * Emitted when the input value has changed. + * The `ionChange` event is fired for `` elements when the user + * modifies the element's value. Unlike the `ionInput` event, the `ionChange` + * event is not necessarily fired for each alteration to an element's value. + * + * The `ionChange` event is fired when the element loses focus after its value + * has been modified. */ @Event() ionChange!: EventEmitter; /** - * Emitted when a keyboard input occurred. + * Ths `ionInput` event fires when the `value` of an `` element + * has been changed. */ - @Event() ionInput!: EventEmitter; + @Event() ionInput!: EventEmitter; /** * Emitted when the styles change. @@ -252,6 +261,21 @@ export class Textarea implements ComponentInterface { }); } + /** + * Emits an `ionChange` event. + * + * This API should be called for user committed changes. + * This API should not be used for external value changes. + */ + private emitValueChange() { + const { value } = this; + // Checks for both null and undefined values + const newValue = value == null ? value : value.toString(); + // Emitting a value change should update the internal state for tracking the focused value + this.focusedValue = newValue; + this.ionChange.emit({ value: newValue }); + } + private runAutoGrow() { if (this.nativeInput && this.autoGrow) { writeTask(() => { @@ -278,6 +302,8 @@ export class Textarea implements ComponentInterface { this.value = ''; } + this.ionInput.emit(null); + // Reset the flag this.didBlurAfterEdit = false; } @@ -298,16 +324,26 @@ export class Textarea implements ComponentInterface { return this.value || ''; } + // `Event` type is used instead of `InputEvent` + // since the types from Stencil are not derived + // from the element (e.g. textarea and input + // should be InputEvent, but all other elements + // should be Event). private onInput = (ev: Event) => { - if (this.nativeInput) { - this.value = this.nativeInput.value; + const input = ev.target as HTMLTextAreaElement | null; + if (input) { + this.value = input.value || ''; } - this.emitStyle(); this.ionInput.emit(ev as InputEvent); }; + private onChange = () => { + this.emitValueChange(); + }; + private onFocus = (ev: FocusEvent) => { this.hasFocus = true; + this.focusedValue = this.value; this.focusChange(); this.ionFocus.emit(ev); @@ -317,6 +353,14 @@ export class Textarea implements ComponentInterface { this.hasFocus = false; this.focusChange(); + if (this.focusedValue !== this.value) { + /** + * Emits the `ionChange` event when the textarea value + * is different than the value when the textarea was focused. + */ + this.emitValueChange(); + } + this.ionBlur.emit(ev); }; @@ -361,6 +405,7 @@ export class Textarea implements ComponentInterface { rows={this.rows} wrap={this.wrap} onInput={this.onInput} + onChange={this.onChange} onBlur={this.onBlur} onFocus={this.onFocus} onKeyDown={this.onKeyDown}