Skip to content

Commit

Permalink
feat(textarea): ionChange will only emit from user committed changes (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sean-perkins committed Sep 23, 2022
1 parent a03c8af commit 68bae80
Show file tree
Hide file tree
Showing 15 changed files with 280 additions and 33 deletions.
12 changes: 12 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)

<h4 id="version-7x-textarea">Textarea</h4>

- `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"`.


<h4 id="version-7x-virtual-scroll">Virtual Scroll</h4>

`ion-virtual-scroll` has been removed from Ionic.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions angular/src/directives/proxies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<ion-textarea>` 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<CustomEvent<ITextareaTextareaChangeEventDetail>>;
/**
* Emitted when a keyboard input occurred.
* Ths `ionInput` event fires when the `value` of an `<ion-textarea>` element
has been changed.
*/
ionInput: EventEmitter<CustomEvent<InputEvent>>;
ionInput: EventEmitter<CustomEvent<InputEvent | null>>;
/**
* Emitted when the input loses focus.
*/
Expand Down
18 changes: 18 additions & 0 deletions angular/test/base/e2e/src/textarea.spec.ts
Original file line number Diff line number Diff line change
@@ -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"`);
});
});
1 change: 1 addition & 0 deletions angular/test/base/src/app/app-routing.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
2 changes: 1 addition & 1 deletion angular/test/base/src/app/form/form.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
Form Status: <span id="status">{{ profileForm.status }}</span>
</p>
<p>
Form Status: <span id="data">{{ profileForm.value | json }}</span>
Form value: <span id="data">{{ profileForm.value | json }}</span>
</p>
<p>
Form Submit: <span id="submit">{{submitted}}</span>
Expand Down
16 changes: 16 additions & 0 deletions angular/test/base/src/app/textarea/textarea-routing.module.ts
Original file line number Diff line number Diff line change
@@ -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 { }
16 changes: 16 additions & 0 deletions angular/test/base/src/app/textarea/textarea.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<ion-content>
<form [formGroup]="form">
<ion-list>
<ion-item>
<ion-label>Textarea</ion-label>
<ion-textarea formControlName="textarea"></ion-textarea>
</ion-item>
</ion-list>
</form>
<p>
Form status: <span id="status">{{ form.status }}</span>
</p>
<p>
Form value: <span id="value">{{ form.value | json }}</span>
</p>
</ion-content>
17 changes: 17 additions & 0 deletions angular/test/base/src/app/textarea/textarea.component.ts
Original file line number Diff line number Diff line change
@@ -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) { }

}
21 changes: 21 additions & 0 deletions angular/test/base/src/app/textarea/textarea.module.ts
Original file line number Diff line number Diff line change
@@ -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 { }
2 changes: 1 addition & 1 deletion core/api.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,7 @@ ion-textarea,method,setFocus,setFocus() => Promise<void>
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
Expand Down
22 changes: 11 additions & 11 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -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;
/**
Expand All @@ -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;
/**
Expand Down Expand Up @@ -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;
/**
Expand All @@ -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;
/**
Expand All @@ -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;
/**
Expand All @@ -6566,17 +6566,17 @@ declare namespace LocalJSX {
*/
"onIonBlur"?: (event: IonTextareaCustomEvent<FocusEvent>) => void;
/**
* Emitted when the input value has changed.
* The `ionChange` event is fired for `<ion-textarea>` 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<TextareaChangeEventDetail>) => void;
/**
* Emitted when the input has focus.
*/
"onIonFocus"?: (event: IonTextareaCustomEvent<FocusEvent>) => void;
/**
* Emitted when a keyboard input occurred.
* Ths `ionInput` event fires when the `value` of an `<ion-textarea>` element has been changed.
*/
"onIonInput"?: (event: IonTextareaCustomEvent<InputEvent>) => void;
"onIonInput"?: (event: IonTextareaCustomEvent<InputEvent | null>) => void;
/**
* Emitted when the styles change.
*/
Expand Down
3 changes: 2 additions & 1 deletion core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand All @@ -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 || '';
Expand Down
93 changes: 93 additions & 0 deletions core/src/components/textarea/test/textarea-events.e2e.ts
Original file line number Diff line number Diff line change
@@ -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(`<ion-textarea></ion-textarea>`);

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(`<ion-textarea clear-on-edit="true" value="123"></ion-textarea>`);

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(`<ion-textarea></ion-textarea>`);

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(`<ion-textarea value="some value"></ion-textarea>`);

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(`<ion-textarea clear-on-edit="true" value="some value"></ion-textarea>`);

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);
});
});

0 comments on commit 68bae80

Please sign in to comment.