Merge branch 'feature-7.0' into 7-sync-09-23-22

This commit is contained in:
Liam DeBeasi
2022-09-23 16:34:53 -05:00
committed by GitHub
15 changed files with 280 additions and 33 deletions

View File

@ -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)
<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.

View File

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

View File

@ -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.
*/

View File

@ -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"`);
});
});

View File

@ -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) },

View File

@ -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>

View File

@ -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 { }

View File

@ -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>

View File

@ -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) { }
}

View File

@ -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 { }

View File

@ -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

View File

@ -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<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;
/**
@ -6574,9 +6574,9 @@ declare namespace LocalJSX {
*/
"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.
*/

View File

@ -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 || '';

View File

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

View File

@ -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 `<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.
*/
@Event() ionChange!: EventEmitter<TextareaChangeEventDetail>;
/**
* Emitted when a keyboard input occurred.
* Ths `ionInput` event fires when the `value` of an `<ion-textarea>` element
* has been changed.
*/
@Event() ionInput!: EventEmitter<InputEvent>;
@Event() ionInput!: EventEmitter<InputEvent | null>;
/**
* 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}