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"`.
+
+
`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 @@
+
+
+
+ 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}