feat(searchbar): ionChange will only emit from user committed changes (#26026)

This commit is contained in:
Amanda Johnston
2022-10-03 15:08:43 -05:00
committed by GitHub
parent 4cb32b6c6b
commit b052d3b262
17 changed files with 302 additions and 70 deletions

View File

@@ -2366,9 +2366,9 @@ export namespace Components {
*/
"color"?: Color;
/**
* 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;
"debounce"?: number;
/**
* If `true`, the user cannot interact with the input.
*/
@@ -6108,7 +6108,7 @@ declare namespace LocalJSX {
*/
"color"?: Color;
/**
* 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;
/**
@@ -6136,7 +6136,7 @@ declare namespace LocalJSX {
*/
"onIonCancel"?: (event: IonSearchbarCustomEvent<void>) => void;
/**
* Emitted when the value has changed.
* The `ionChange` event is fired for `<ion-searchbar>` 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. This includes modifications made when clicking the clear or cancel buttons.
*/
"onIonChange"?: (event: IonSearchbarCustomEvent<SearchbarChangeEventDetail>) => void;
/**
@@ -6148,9 +6148,9 @@ declare namespace LocalJSX {
*/
"onIonFocus"?: (event: IonSearchbarCustomEvent<void>) => void;
/**
* Emitted when a keyboard input occurred.
* Emitted when the `value` of the `ion-searchbar` element has changed.
*/
"onIonInput"?: (event: IonSearchbarCustomEvent<KeyboardEvent>) => void;
"onIonInput"?: (event: IonSearchbarCustomEvent<KeyboardEvent | null>) => void;
/**
* Emitted when the styles change.
*/

View File

@@ -11,6 +11,7 @@ test.describe('radio-group', () => {
test('radio should remain checked after being removed/readded to the dom', async ({ page }) => {
const radioGroup = page.locator('ion-radio-group');
const radio = page.locator('ion-radio[value=two]');
const searchbarInput = page.locator('ion-searchbar input');
// select radio
await radio.click();
@@ -18,6 +19,7 @@ test.describe('radio-group', () => {
// filter radio so it is not in DOM
await page.fill('ion-searchbar input', 'zero');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
expect(radio).toBeHidden();
@@ -26,6 +28,7 @@ test.describe('radio-group', () => {
// clear the search so the radio appears
await page.fill('ion-searchbar input', '');
await searchbarInput.evaluate((el) => el.blur());
await page.waitForChanges();
// ensure that the new radio instance is still checked

View File

@@ -1,5 +1,5 @@
export interface SearchbarChangeEventDetail {
value?: string;
value?: string | null;
}
export interface SearchbarCustomEvent extends CustomEvent {

View File

@@ -24,6 +24,12 @@ export class Searchbar implements ComponentInterface {
private nativeInput?: HTMLInputElement;
private isCancelVisible = false;
private shouldAlignLeft = true;
private originalIonInput!: EventEmitter<KeyboardEvent | null>;
/**
* The value of the input when the textarea is focused.
*/
private focusedValue?: string | null;
@Element() el!: HTMLIonSearchbarElement;
@@ -69,13 +75,19 @@ export class Searchbar implements ComponentInterface {
@Prop() clearIcon?: string;
/**
* 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 = 250;
@Prop() debounce?: number;
@Watch('debounce')
protected debounceChanged() {
this.ionChange = debounceEvent(this.ionChange, this.debounce);
const { ionInput, debounce, originalIonInput } = this;
/**
* If debounce is undefined, we have to manually revert the ionInput emitter in case
* debounce used to be set to a number. Otherwise, the event would stay debounced.
*/
this.ionInput = debounce === undefined ? originalIonInput : debounceEvent(ionInput, debounce);
}
/**
@@ -149,12 +161,18 @@ export class Searchbar implements ComponentInterface {
@Prop({ mutable: true }) value?: string | null = '';
/**
* Emitted when a keyboard input occurred.
* Emitted when the `value` of the `ion-searchbar` element has changed.
*/
@Event() ionInput!: EventEmitter<KeyboardEvent>;
@Event() ionInput!: EventEmitter<KeyboardEvent | null>;
/**
* Emitted when the value has changed.
* The `ionChange` event is fired for `<ion-searchbar>` 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. This includes modifications made when clicking the clear
* or cancel buttons.
*/
@Event() ionChange!: EventEmitter<SearchbarChangeEventDetail>;
@@ -191,7 +209,6 @@ export class Searchbar implements ComponentInterface {
if (inputEl && inputEl.value !== value) {
inputEl.value = value;
}
this.ionChange.emit({ value });
}
@Watch('showCancelButton')
@@ -207,6 +224,7 @@ export class Searchbar implements ComponentInterface {
}
componentDidLoad() {
this.originalIonInput = this.ionInput;
this.positionElements();
this.debounceChanged();
@@ -240,31 +258,58 @@ export class Searchbar 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;
// 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 });
}
/**
* Clears the input field and triggers the control change.
*/
private onClearInput = (shouldFocus?: boolean) => {
private onClearInput = async (shouldFocus?: boolean) => {
this.ionClear.emit();
// setTimeout() fixes https://github.com/ionic-team/ionic/issues/7527
// wait for 4 frames
setTimeout(() => {
const value = this.getValue();
if (value !== '') {
this.value = '';
this.ionInput.emit();
return new Promise<void>((resolve) => {
// setTimeout() fixes https://github.com/ionic-team/ionic/issues/7527
// wait for 4 frames
setTimeout(() => {
const value = this.getValue();
if (value !== '') {
this.value = '';
this.ionInput.emit(null);
/**
* When tapping clear button
* ensure input is focused after
* clearing input so users
* can quickly start typing.
*/
if (shouldFocus && !this.focused) {
this.setFocus();
/**
* When tapping clear button
* ensure input is focused after
* clearing input so users
* can quickly start typing.
*/
if (shouldFocus && !this.focused) {
this.setFocus();
/**
* The setFocus call above will clear focusedValue,
* but ionChange will never have gotten a chance to
* fire. Manually revert focusedValue so onBlur can
* compare against what was in the box before the clear.
*/
this.focusedValue = value;
}
}
}
}, 16 * 4);
resolve();
}, 16 * 4);
});
};
/**
@@ -272,13 +317,27 @@ export class Searchbar implements ComponentInterface {
* the clearInput function doesn't want the input to blur
* then calls the custom cancel function if the user passed one in.
*/
private onCancelSearchbar = (ev?: Event) => {
private onCancelSearchbar = async (ev?: Event) => {
if (ev) {
ev.preventDefault();
ev.stopPropagation();
}
this.ionCancel.emit();
this.onClearInput();
// get cached values before clearing the input
const value = this.getValue();
const focused = this.focused;
await this.onClearInput();
/**
* If there used to be something in the box, and we weren't focused
* beforehand (meaning no blur fired that would already handle this),
* manually fire ionChange.
*/
if (value && !focused) {
this.emitValueChange();
}
if (this.nativeInput) {
this.nativeInput.blur();
@@ -296,6 +355,10 @@ export class Searchbar implements ComponentInterface {
this.ionInput.emit(ev as KeyboardEvent);
};
private onChange = () => {
this.emitValueChange();
};
/**
* Sets the Searchbar to not focused and checks if it should align left
* based on whether there is a value in the searchbar or not.
@@ -304,6 +367,11 @@ export class Searchbar implements ComponentInterface {
this.focused = false;
this.ionBlur.emit();
this.positionElements();
if (this.focusedValue !== this.value) {
this.emitValueChange();
}
this.focusedValue = undefined;
};
/**
@@ -311,6 +379,7 @@ export class Searchbar implements ComponentInterface {
*/
private onFocus = () => {
this.focused = true;
this.focusedValue = this.value;
this.ionFocus.emit();
this.positionElements();
};
@@ -503,6 +572,7 @@ export class Searchbar implements ComponentInterface {
inputMode={this.inputmode}
enterKeyHint={this.enterkeyhint}
onInput={this.onInput}
onChange={this.onChange}
onBlur={this.onBlur}
onFocus={this.onFocus}
placeholder={this.placeholder}

View File

@@ -0,0 +1,83 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
test.describe('searchbar: events (ionChange)', () => {
test.beforeEach(({ skip }) => {
skip.rtl();
});
test('should emit when blurred after value change', async ({ page }) => {
await page.setContent(`<ion-searchbar></ion-searchbar>`);
const nativeInput = page.locator('ion-searchbar input');
const ionChange = await page.spyOnEvent('ionChange');
await nativeInput.type('new value', { delay: 100 });
await nativeInput.evaluate((e) => e.blur());
await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: 'new value' });
expect(ionChange).toHaveReceivedEventTimes(1);
});
test('should emit when blurred after clicking clear with default value', async ({ page }) => {
await page.setContent(`<ion-searchbar show-clear-button="always" value="default value"></ion-searchbar>`);
const nativeInput = page.locator('ion-searchbar input');
const ionChange = await page.spyOnEvent('ionChange');
await page.click('.searchbar-clear-button');
await page.waitForChanges();
await nativeInput.evaluate((e) => e.blur());
await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: '' });
expect(ionChange).toHaveReceivedEventTimes(1);
});
test('should emit after clicking cancel with default value', async ({ page }) => {
await page.setContent(`<ion-searchbar show-cancel-button="always" value="default value"></ion-searchbar>`);
const ionChange = await page.spyOnEvent('ionChange');
await page.click('.searchbar-cancel-button');
await page.waitForChanges();
await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: '' });
expect(ionChange).toHaveReceivedEventTimes(1);
});
test('should not emit if the value is set programmatically', async ({ page }) => {
await page.setContent(`<ion-searchbar></ion-searchbar>`);
const searchbar = page.locator('ion-searchbar');
const ionChange = await page.spyOnEvent('ionChange');
await searchbar.evaluate((el: HTMLIonSearchbarElement) => {
el.value = 'new value';
});
await page.waitForChanges();
expect(ionChange).toHaveReceivedEventTimes(0);
// Update the value again to make sure it doesn't emit a second time
await searchbar.evaluate((el: HTMLIonSearchbarElement) => {
el.value = 'new value 2';
});
await page.waitForChanges();
expect(ionChange).toHaveReceivedEventTimes(0);
});
});
test.describe('searchbar: events (ionInput)', () => {
test.beforeEach(({ skip }) => {
skip.rtl();
});
test('should emit when the user types', async ({ page }) => {
await page.setContent(`<ion-searchbar debounce="0"></ion-searchbar>`);
const nativeInput = page.locator('ion-searchbar input');
const ionInput = await page.spyOnEvent('ionInput');
await nativeInput.type('new value', { delay: 100 });
expect(ionInput).toHaveReceivedEventTimes(9);
});
});