Skip to content

Commit

Permalink
feat(input): ionChange will only emit from user committed changes (#2…
Browse files Browse the repository at this point in the history
…5858)

Resolves #20106, #20061
  • Loading branch information
sean-perkins committed Sep 12, 2022
1 parent ba6b539 commit 8732b7b
Show file tree
Hide file tree
Showing 18 changed files with 225 additions and 30 deletions.
7 changes: 7 additions & 0 deletions BREAKING.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver

- [Browser and Platform Support](#version-7x-browser-platform-support)
- [Components](#version-7x-components)
- [Input](#version-7x-input)
- [Overlays](#version-7x-overlays)
- [Range](#version-7x-range)
- [Slides](#version-7x-slides)
Expand Down Expand Up @@ -50,6 +51,12 @@ This section details the desktop browser, JavaScript framework, and mobile platf

<h2 id="version-7x-components">Components</h2>

<h4 id="version-7x-input">Input</h4>

`ionChange` is no longer emitted when the `value` of `ion-input` is modified externally. `ionChange` is only emitted from user committed changes, such as typing in the input and the input losing focus or from clicking the clear action within the input.

If your application requires immediate feedback based on the user typing actively in the input, consider migrating your event listeners to using `ionInput` instead.

<h4 id="version-7x-overlays">Overlays</h4>

Ionic now listens on the `keydown` event instead of the `keyup` event when determining when to dismiss overlays via the "Escape" key. Any applications that were listening on `keyup` to suppress this behavior should listen on `keydown` instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ export class BooleanValueAccessorDirective extends ValueAccessor {

@HostListener('ionChange', ['$event.target'])
_handleIonChange(el: any): void {
this.handleChangeEvent(el, el.checked);
this.handleValueChange(el, el.checked);
}
}
5 changes: 5 additions & 0 deletions angular/src/directives/control-value-accessors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './boolean-value-accessor';
export * from './numeric-value-accessor';
export * from './radio-value-accessor';
export * from './select-value-accessor';
export * from './text-value-accessor';
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export class NumericValueAccessorDirective extends ValueAccessor {
super(injector, el);
}

@HostListener('ionChange', ['$event.target'])
_handleIonChange(el: any): void {
this.handleChangeEvent(el, el.value);
@HostListener('ionInput', ['$event.target'])
handleInputEvent(el: HTMLIonInputElement): void {
this.handleValueChange(el, el.value);
}

registerOnChange(fn: (_: number | null) => void): void {
super.registerOnChange((value) => {
super.registerOnChange((value: string) => {
fn(value === '' ? null : parseFloat(value));
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ export class RadioValueAccessorDirective extends ValueAccessor {

@HostListener('ionSelect', ['$event.target'])
_handleIonSelect(el: any): void {
this.handleChangeEvent(el, el.checked);
this.handleValueChange(el, el.checked);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ export class SelectValueAccessorDirective extends ValueAccessor {

@HostListener('ionChange', ['$event.target'])
_handleChangeEvent(el: any): void {
this.handleChangeEvent(el, el.value);
this.handleValueChange(el, el.value);
}
}
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-input:not([type=number]),ion-textarea,ion-searchbar',
selector: 'ion-textarea,ion-searchbar',
providers: [
{
provide: NG_VALUE_ACCESSOR,
Expand All @@ -21,6 +21,27 @@ export class TextValueAccessorDirective extends ValueAccessor {

@HostListener('ionChange', ['$event.target'])
_handleInputEvent(el: any): void {
this.handleChangeEvent(el, el.value);
this.handleValueChange(el, el.value);
}
}

@Directive({
selector: 'ion-input:not([type=number])',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: InputValueAccessorDirective,
multi: true,
},
],
})
export class InputValueAccessorDirective extends ValueAccessor {
constructor(injector: Injector, el: ElementRef) {
super(injector, el);
}

@HostListener('ionInput', ['$event.target'])
_handleInputEvent(el: any): void {
this.handleValueChange(el, el.value);
}
}
15 changes: 14 additions & 1 deletion angular/src/directives/control-value-accessors/value-accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,20 @@ export class ValueAccessor implements ControlValueAccessor, AfterViewInit, OnDes
setIonicClasses(this.el);
}

handleChangeEvent(el: HTMLElement, value: any): void {
/**
* Notifies the ControlValueAccessor of a change in the value of the control.
*
* This is called by each of the ValueAccessor directives when we want to update
* the status and validity of the form control. For example with text components this
* is called when the ionInput event is fired. For select components this is called
* when the ionChange event is fired.
*
* This also updates the Ionic form status classes on the element.
*
* @param el The component element.
* @param value The new value of the control.
*/
handleValueChange(el: HTMLElement, value: any): void {
if (el === this.el.nativeElement) {
if (value !== this.lastValue) {
this.lastValue = value;
Expand Down
5 changes: 4 additions & 1 deletion angular/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ export { BooleanValueAccessorDirective as BooleanValueAccessor } from './directi
export { NumericValueAccessorDirective as NumericValueAccessor } from './directives/control-value-accessors/numeric-value-accessor';
export { RadioValueAccessorDirective as RadioValueAccessor } from './directives/control-value-accessors/radio-value-accessor';
export { SelectValueAccessorDirective as SelectValueAccessor } from './directives/control-value-accessors/select-value-accessor';
export { TextValueAccessorDirective as TextValueAccessor } from './directives/control-value-accessors/text-value-accessor';
export {
TextValueAccessorDirective as TextValueAccessor,
InputValueAccessorDirective as InputValueAccessor,
} from './directives/control-value-accessors/text-value-accessor';
export { IonTabs } from './directives/navigation/ion-tabs';
export { IonBackButtonDelegateDirective as IonBackButtonDelegate } from './directives/navigation/ion-back-button';
export { NavDelegate } from './directives/navigation/nav-delegate';
Expand Down
14 changes: 9 additions & 5 deletions angular/src/ionic-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { ModuleWithProviders, APP_INITIALIZER, NgModule, NgZone } from '@angular
import { IonicConfig } from '@ionic/core';

import { appInitialize } from './app-initialize';
import { BooleanValueAccessorDirective } from './directives/control-value-accessors/boolean-value-accessor';
import { NumericValueAccessorDirective } from './directives/control-value-accessors/numeric-value-accessor';
import { RadioValueAccessorDirective } from './directives/control-value-accessors/radio-value-accessor';
import { SelectValueAccessorDirective } from './directives/control-value-accessors/select-value-accessor';
import { TextValueAccessorDirective } from './directives/control-value-accessors/text-value-accessor';
import {
BooleanValueAccessorDirective,
NumericValueAccessorDirective,
RadioValueAccessorDirective,
SelectValueAccessorDirective,
TextValueAccessorDirective,
InputValueAccessorDirective,
} from './directives/control-value-accessors';
import { IonBackButtonDelegateDirective } from './directives/navigation/ion-back-button';
import { IonRouterOutlet } from './directives/navigation/ion-router-outlet';
import { IonTabs } from './directives/navigation/ion-tabs';
Expand Down Expand Up @@ -38,6 +41,7 @@ const DECLARATIONS = [
RadioValueAccessorDirective,
SelectValueAccessorDirective,
TextValueAccessorDirective,
InputValueAccessorDirective,

// navigation
IonTabs,
Expand Down
4 changes: 3 additions & 1 deletion angular/test/base/e2e/src/form.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ describe('Form', () => {
});

it('should become valid', () => {
cy.get('ion-input.required').invoke('prop', 'value', 'Some value');
cy.get('ion-input.required').type('Some value');
cy.get('ion-input.required input').blur();

testStatus('INVALID');

// TODO: FW-1160 - Remove when v7 is released
Expand Down
5 changes: 4 additions & 1 deletion angular/test/base/e2e/src/inputs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@ describe('Inputs', () => {

cy.get('ion-checkbox').invoke('prop', 'checked', true);
cy.get('ion-toggle').invoke('prop', 'checked', true);
cy.get('ion-input').invoke('prop', 'value', 'hola');

cy.get('ion-input').eq(0).type('hola');
cy.get('ion-input input').eq(0).blur();

cy.get('ion-datetime').invoke('prop', 'value', '1996-03-15');
cy.get('ion-select').invoke('prop', 'value', 'playstation');
cy.get('ion-range').invoke('prop', 'value', 20);
Expand Down
8 changes: 4 additions & 4 deletions core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -4898,17 +4898,17 @@ 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;
/**
* Emitted when the input has focus.
*/
"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.
*/
Expand Down
4 changes: 4 additions & 0 deletions core/src/components/input/input-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
67 changes: 63 additions & 4 deletions core/src/components/input/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
AutocompleteTypes,
Color,
InputChangeEventDetail,
InputInputEventDetail,
StyleEventDetail,
TextFieldTypes,
} from '../../interface';
Expand All @@ -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;

Expand Down Expand Up @@ -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>;

Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -386,6 +441,9 @@ export class Input implements ComponentInterface {
}

this.value = '';
this.inputCleared = true;

this.ionInput.emit(ev);

/**
* This is needed for clearOnEdit
Expand Down Expand Up @@ -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}
Expand Down

0 comments on commit 8732b7b

Please sign in to comment.