feat(input): ionChange will only emit from user committed changes (#25858)

Resolves #20106, #20061
This commit is contained in:
Sean Perkins
2022-09-12 15:35:43 -04:00
committed by GitHub
parent ba6b539675
commit 8732b7bdb7
18 changed files with 225 additions and 30 deletions

View File

@@ -5,7 +5,7 @@
* It contains typing information for all components that exist in this project.
*/
import { HTMLStencilElement, JSXBase } from "@stencil/core/internal";
import { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimePresentation, FrameworkDelegate, InputChangeEventDetail, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, ModalBreakpointChangeEventDetail, ModalHandleBehavior, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface";
import { AccordionGroupChangeEventDetail, ActionSheetAttributes, ActionSheetButton, AlertButton, AlertInput, AnimationBuilder, AutocompleteTypes, BreadcrumbCollapsedClickEventDetail, CheckboxChangeEventDetail, Color, ComponentProps, ComponentRef, DatetimeChangeEventDetail, DatetimePresentation, FrameworkDelegate, InputChangeEventDetail, InputInputEventDetail, ItemReorderEventDetail, LoadingAttributes, MenuChangeEventDetail, ModalAttributes, ModalBreakpointChangeEventDetail, ModalHandleBehavior, NavComponent, NavComponentWithProps, NavOptions, OverlayEventDetail, PickerAttributes, PickerButton, PickerColumn, PopoverAttributes, PopoverSize, PositionAlign, PositionReference, PositionSide, RadioGroupChangeEventDetail, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue, RefresherEventDetail, RouteID, RouterDirection, RouterEventDetail, RouterOutletOptions, RouteWrite, ScrollBaseDetail, ScrollDetail, SearchbarChangeEventDetail, SegmentButtonLayout, SegmentChangeEventDetail, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, Side, SpinnerTypes, StyleEventDetail, SwipeGestureHandler, TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout, TextareaChangeEventDetail, TextFieldTypes, ToastButton, ToggleChangeEventDetail, TransitionDoneFn, TransitionInstruction, TriggerAction, ViewController } from "./interface";
import { IonicSafeString } from "./utils/sanitization";
import { AlertAttributes } from "./components/alert/alert-interface";
import { CounterFormatter } from "./components/item/item-interface";
@@ -4898,7 +4898,7 @@ declare namespace LocalJSX {
*/
"onIonBlur"?: (event: IonInputCustomEvent<FocusEvent>) => void;
/**
* Emitted when the value has changed.
* The `ionChange` event is fired for `<ion-input>` 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. Depending on the way the users interacts with the element, the `ionChange` event fires at a different moment: - When the user commits the change explicitly (e.g. by selecting a date from a date picker for `<ion-input type="date">`, etc.). - When the element loses focus after its value has changed: for elements where the user's interaction is typing.
*/
"onIonChange"?: (event: IonInputCustomEvent<InputChangeEventDetail>) => void;
/**
@@ -4906,9 +4906,9 @@ declare namespace LocalJSX {
*/
"onIonFocus"?: (event: IonInputCustomEvent<FocusEvent>) => void;
/**
* Emitted when a keyboard input occurred.
* The `ionInput` event fires when the `value` of an `<ion-input>` element has been changed. For elements that accept text input (`type=text`, `type=tel`, etc.), the interface is [`InputEvent`](https://developer.mozilla.org/en-US/docs/Web/API/InputEvent); for others, the interface is [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event).
*/
"onIonInput"?: (event: IonInputCustomEvent<InputEvent>) => void;
"onIonInput"?: (event: IonInputCustomEvent<InputInputEventDetail>) => void;
/**
* Emitted when the styles change.
*/

View File

@@ -2,6 +2,10 @@ export interface InputChangeEventDetail {
value: string | undefined | null;
}
// We recognize that InputInput is not an ideal naming pattern for this type.
// TODO (FW-2199): Explore renaming this type to something more appropriate.
export type InputInputEventDetail = InputEvent | Event;
export interface InputCustomEvent extends CustomEvent {
detail: InputChangeEventDetail;
target: HTMLIonInputElement;

View File

@@ -6,6 +6,7 @@ import type {
AutocompleteTypes,
Color,
InputChangeEventDetail,
InputInputEventDetail,
StyleEventDetail,
TextFieldTypes,
} from '../../interface';
@@ -30,6 +31,17 @@ export class Input implements ComponentInterface {
private didBlurAfterEdit = false;
private inheritedAttributes: Attributes = {};
private isComposing = false;
/**
* If `true`, the user cleared the input by pressing the clear icon,
* within the session of the input being focused.
*
* This property is reset to `false` when the input is blurred.
*/
private inputCleared = false;
/**
* The value of the input when the input is focused.
*/
private focusedValue: string | number | null | undefined;
@State() hasFocus = false;
@@ -192,12 +204,27 @@ export class Input implements ComponentInterface {
@Prop({ mutable: true }) value?: string | number | null = '';
/**
* Emitted when a keyboard input occurred.
* The `ionInput` event fires when the `value` of an `<ion-input>` element
* has been changed.
*
* For elements that accept text input (`type=text`, `type=tel`, etc.), the interface
* is [`InputEvent`](https://developer.mozilla.org/en-US/docs/Web/API/InputEvent); for others,
* the interface is [`Event`](https://developer.mozilla.org/en-US/docs/Web/API/Event).
*/
@Event() ionInput!: EventEmitter<InputEvent>;
@Event() ionInput!: EventEmitter<InputInputEventDetail>;
/**
* Emitted when the value has changed.
* The `ionChange` event is fired for `<ion-input>` 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.
*
* Depending on the way the users interacts with the element, the `ionChange`
* event fires at a different moment:
* - When the user commits the change explicitly (e.g. by selecting a date
* from a date picker for `<ion-input type="date">`, etc.).
* - When the element loses focus after its value has changed: for elements
* where the user's interaction is typing.
*
*/
@Event() ionChange!: EventEmitter<InputChangeEventDetail>;
@@ -244,7 +271,6 @@ export class Input implements ComponentInterface {
nativeInput.value = value;
}
this.emitStyle();
this.ionChange.emit({ value: this.value == null ? this.value : this.value.toString() });
}
componentWillLoad() {
@@ -310,6 +336,18 @@ export class Input implements ComponentInterface {
return Promise.resolve(this.nativeInput!);
}
/**
* 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;
const newValue = value == null ? value : value.toString();
this.ionChange.emit({ value: newValue });
}
private shouldClearOnEdit() {
const { type, clearOnEdit } = this;
return clearOnEdit === undefined ? type === 'password' : clearOnEdit;
@@ -338,16 +376,33 @@ export class Input implements ComponentInterface {
this.ionInput.emit(ev as InputEvent);
};
private onChange = () => {
this.emitValueChange();
};
private onBlur = (ev: FocusEvent) => {
this.hasFocus = false;
this.focusChanged();
this.emitStyle();
if (this.inputCleared) {
if (this.focusedValue !== this.value) {
/**
* Emits the `ionChange` event when the input value
* is different than the value when the input was focused.
*/
this.emitValueChange();
}
this.focusedValue = undefined;
this.inputCleared = false;
}
this.ionBlur.emit(ev);
};
private onFocus = (ev: FocusEvent) => {
this.hasFocus = true;
this.focusedValue = this.value;
this.focusChanged();
this.emitStyle();
@@ -386,6 +441,9 @@ export class Input implements ComponentInterface {
}
this.value = '';
this.inputCleared = true;
this.ionInput.emit(ev);
/**
* This is needed for clearOnEdit
@@ -454,6 +512,7 @@ export class Input implements ComponentInterface {
type={this.type}
value={value}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
onKeyDown={this.onKeydown}

View File

@@ -0,0 +1,74 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('input: events: ionChange', () => {
test.describe('when the input is blurred', () => {
test.describe('should emit', () => {
test('if the value has changed', async ({ page }) => {
await page.setContent(`<ion-input></ion-input>`);
const nativeInput = page.locator('ion-input input');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await nativeInput.type('new value', { delay: 100 });
// Value change is not emitted until the control is blurred.
await nativeInput.evaluate((e) => e.blur());
await ionChangeSpy.next();
expect(ionChangeSpy).toHaveReceivedEventDetail({ value: 'new value' });
});
});
test.describe('should not emit', () => {
test('if the value has not changed', async ({ page }) => {
await page.setContent(`<ion-input value="" clear-input="true"></ion-input>`);
const ionChangeSpy = await page.spyOnEvent('ionChange');
const nativeInput = page.locator('ion-input input');
await nativeInput.type('new value', { delay: 100 });
await page.click('ion-input .input-clear-icon');
await nativeInput.evaluate((e) => e.blur());
expect(ionChangeSpy.events.length).toBe(0);
});
test('if the value is set programmatically', async ({ page }) => {
await page.setContent(`<ion-input></ion-input>`);
const input = page.locator('ion-input');
const ionChangeSpy = await page.spyOnEvent('ionChange');
await input.evaluate((el: HTMLIonInputElement) => {
el.value = 'new value';
});
expect(ionChangeSpy.events.length).toBe(0);
// Update the value again to make sure it doesn't emit a second time
await input.evaluate((el: HTMLIonInputElement) => {
el.value = 'new value 2';
});
expect(ionChangeSpy.events.length).toBe(0);
});
});
});
});
test.describe('input: events: ionInput', () => {
test.describe('should emit', () => {
test('when the input is cleared', async ({ page }) => {
await page.setContent(`<ion-input value="some value" clear-input="true"></ion-input>`);
const ionInputSpy = await page.spyOnEvent('ionInput');
await page.click('ion-input .input-clear-icon');
expect(ionInputSpy).toHaveReceivedEventDetail({ isTrusted: true });
});
});
});

View File

@@ -8,7 +8,7 @@ import type { AnchorInterface, ButtonInterface } from '../../utils/element-inter
import { raf } from '../../utils/helpers';
import { printIonError } from '../../utils/logging';
import { createColorClasses, hostContext, openURL } from '../../utils/theme';
import type { InputChangeEventDetail } from '../input/input-interface';
import type { InputInputEventDetail } from '../input/input-interface';
import type { CounterFormatter } from './item-interface';
@@ -149,8 +149,8 @@ export class Item implements ComponentInterface, AnchorInterface, ButtonInterfac
this.updateCounterOutput(this.getFirstInput());
}
@Listen('ionChange')
handleIonChange(ev: CustomEvent<InputChangeEventDetail>) {
@Listen('ionInput')
handleIonInput(ev: CustomEvent<InputInputEventDetail>) {
if (this.counter && ev.target === this.getFirstInput()) {
this.updateCounterOutput(ev.target as HTMLIonInputElement | HTMLIonTextareaElement);
}

View File

@@ -24,7 +24,7 @@ test.describe('item: counter', () => {
test('should format on input', async ({ page }) => {
const input = page.locator('#customFormatter ion-input');
await input.click();
await page.click('#customFormatter ion-input input');
await input.type('abcde');
await page.waitForChanges();