mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-20 04:14:21 +08:00
feat(select): ionChange will only emit from user committed changes (#26066)
BREAKING CHANGE: `ionChange` is no longer emitted when the `value` of `ion-select` is modified externally. `ionChange` is only emitted from user committed changes, such as confirming a selected option in the select's overlay.
This commit is contained in:
@ -22,6 +22,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
|
|||||||
- [Range](#version-7x-range)
|
- [Range](#version-7x-range)
|
||||||
- [Searchbar](#version-7x-searchbar)
|
- [Searchbar](#version-7x-searchbar)
|
||||||
- [Segment](#version-7x-segment)
|
- [Segment](#version-7x-segment)
|
||||||
|
- [Select](#version-7x-select)
|
||||||
- [Slides](#version-7x-slides)
|
- [Slides](#version-7x-slides)
|
||||||
- [Textarea](#version-7x-textarea)
|
- [Textarea](#version-7x-textarea)
|
||||||
- [Virtual Scroll](#version-7x-virtual-scroll)
|
- [Virtual Scroll](#version-7x-virtual-scroll)
|
||||||
@ -119,6 +120,10 @@ iOS:
|
|||||||
|
|
||||||
- The type signature of `value` supports `string | undefined`. Previously the type signature was `string | null | undefined`.
|
- The type signature of `value` supports `string | undefined`. Previously the type signature was `string | null | undefined`.
|
||||||
- Developers needing to clear the checked segment item should assign a value of `''` instead of `null`.
|
- Developers needing to clear the checked segment item should assign a value of `''` instead of `null`.
|
||||||
|
|
||||||
|
<h4 id="version-7x-select">Select</h4>
|
||||||
|
|
||||||
|
- `ionChange` is no longer emitted when the `value` of `ion-select` is modified externally. `ionChange` is only emitted from user committed changes, such as confirming a selected option in the select's overlay.
|
||||||
|
|
||||||
<h4 id="version-7x-slides">Slides</h4>
|
<h4 id="version-7x-slides">Slides</h4>
|
||||||
|
|
||||||
|
@ -36,6 +36,10 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte
|
|||||||
this.onInit++;
|
this.onInit++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelect(value: null | undefined) {
|
||||||
|
this.form.get('select').setValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
ionViewWillEnter() {
|
ionViewWillEnter() {
|
||||||
if (this.onInit !== 1) {
|
if (this.onInit !== 1) {
|
||||||
throw new Error('ngOnInit was not called');
|
throw new Error('ngOnInit was not called');
|
||||||
|
@ -36,6 +36,10 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte
|
|||||||
this.onInit++;
|
this.onInit++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelect(value: null | undefined) {
|
||||||
|
this.form.get('select').setValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
ionViewWillEnter() {
|
ionViewWillEnter() {
|
||||||
if (this.onInit !== 1) {
|
if (this.onInit !== 1) {
|
||||||
throw new Error('ngOnInit was not called');
|
throw new Error('ngOnInit was not called');
|
||||||
|
@ -44,7 +44,13 @@ describe('Form', () => {
|
|||||||
// TODO: FW-1160 - Remove when v7 is released
|
// TODO: FW-1160 - Remove when v7 is released
|
||||||
cy.wait(300);
|
cy.wait(300);
|
||||||
|
|
||||||
cy.get('ion-select').invoke('prop', 'value', 'nes');
|
cy.get('ion-select').click();
|
||||||
|
cy.get('ion-alert').should('exist').should('be.visible');
|
||||||
|
// NES option
|
||||||
|
cy.get('ion-alert .alert-radio-button:nth-of-type(2)').click();
|
||||||
|
// Click confirm button
|
||||||
|
cy.get('ion-alert .alert-button:not(.alert-button-role-cancel)').click();
|
||||||
|
|
||||||
testStatus('INVALID');
|
testStatus('INVALID');
|
||||||
|
|
||||||
cy.get('ion-range').invoke('prop', 'value', 40);
|
cy.get('ion-range').invoke('prop', 'value', 40);
|
||||||
|
@ -45,14 +45,21 @@ describe('Inputs', () => {
|
|||||||
cy.get('ion-input input').eq(0).blur();
|
cy.get('ion-input input').eq(0).blur();
|
||||||
|
|
||||||
cy.get('ion-datetime').invoke('prop', 'value', '1996-03-15');
|
cy.get('ion-datetime').invoke('prop', 'value', '1996-03-15');
|
||||||
cy.get('ion-select').invoke('prop', 'value', 'playstation');
|
|
||||||
|
cy.get('ion-select#game-console').click();
|
||||||
|
cy.get('ion-alert').should('exist').should('be.visible');
|
||||||
|
// Playstation option
|
||||||
|
cy.get('ion-alert .alert-radio-button:nth-of-type(4)').click();
|
||||||
|
// Click confirm button
|
||||||
|
cy.get('ion-alert .alert-button:not(.alert-button-role-cancel)').click();
|
||||||
|
|
||||||
cy.get('ion-range').invoke('prop', 'value', 20);
|
cy.get('ion-range').invoke('prop', 'value', 20);
|
||||||
|
|
||||||
cy.get('#checkbox-note').should('have.text', 'true');
|
cy.get('#checkbox-note').should('have.text', 'true');
|
||||||
cy.get('#toggle-note').should('have.text', 'true');
|
cy.get('#toggle-note').should('have.text', 'true');
|
||||||
cy.get('#input-note').should('have.text', 'hola');
|
cy.get('#input-note').should('have.text', 'hola');
|
||||||
cy.get('#datetime-note').should('have.text', '1996-03-15');
|
cy.get('#datetime-note').should('have.text', '1996-03-15');
|
||||||
cy.get('#select-note').should('have.text', 'playstation');
|
cy.get('#select-note').should('have.text', 'ps');
|
||||||
cy.get('#range-note').should('have.text', '20');
|
cy.get('#range-note').should('have.text', '20');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -102,18 +102,23 @@ describe('when in a modal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should render ion-item item-has-value class when control value is set', () => {
|
it('should render ion-item item-has-value class when control value is set', () => {
|
||||||
cy.get('[formControlName="select"]').invoke('attr', 'value', 0);
|
cy.get('ion-select').click();
|
||||||
|
cy.get('ion-alert').should('exist').should('be.visible');
|
||||||
|
// Option 0 option
|
||||||
|
cy.get('ion-alert .alert-radio-button:nth-of-type(1)').click();
|
||||||
|
// Click confirm button
|
||||||
|
cy.get('ion-alert .alert-button:not(.alert-button-role-cancel)').click();
|
||||||
|
|
||||||
cy.get('#inputWithFloatingLabel').should('have.class', 'item-has-value');
|
cy.get('#inputWithFloatingLabel').should('have.class', 'item-has-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render ion-item item-has-value class when control value is undefined', () => {
|
it('should not render ion-item item-has-value class when control value is undefined', () => {
|
||||||
cy.get('[formControlName="select"]').invoke('attr', 'value', undefined);
|
cy.get('#set-to-undefined').click();
|
||||||
cy.get('#inputWithFloatingLabel').should('not.have.class', 'item-has-value');
|
cy.get('#inputWithFloatingLabel').should('not.have.class', 'item-has-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not render ion-item item-has-value class when control value is null', () => {
|
it('should not render ion-item item-has-value class when control value is null', () => {
|
||||||
cy.get('[formControlName="select"]').invoke('attr', 'value', null);
|
cy.get('#set-to-null').click();
|
||||||
cy.get('#inputWithFloatingLabel').should('not.have.class', 'item-has-value');
|
cy.get('#inputWithFloatingLabel').should('not.have.class', 'item-has-value');
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label>Select</ion-label>
|
<ion-label>Select</ion-label>
|
||||||
<ion-select [(ngModel)]="select">
|
<ion-select [(ngModel)]="select" id="game-console">
|
||||||
<ion-select-option value="">No Game Console</ion-select-option>
|
<ion-select-option value="">No Game Console</ion-select-option>
|
||||||
<ion-select-option value="nes">NES</ion-select-option>
|
<ion-select-option value="nes">NES</ion-select-option>
|
||||||
<ion-select-option value="n64" selected>Nintendo64</ion-select-option>
|
<ion-select-option value="n64" selected>Nintendo64</ion-select-option>
|
||||||
|
@ -31,5 +31,8 @@
|
|||||||
<ion-select-option [value]="1">Option 1</ion-select-option>
|
<ion-select-option [value]="1">Option 1</ion-select-option>
|
||||||
</ion-select>
|
</ion-select>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-button id="set-to-null" (click)="setSelect(null)">Set select value to "null"></ion-button>
|
||||||
|
<ion-button id="set-to-undefined" (click)="setSelect(undefined)">Set select value to "undefined"></ion-button>
|
||||||
</form>
|
</form>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
@ -36,6 +36,10 @@ export class ModalExampleComponent implements OnInit, ViewWillLeave, ViewDidEnte
|
|||||||
this.onInit++;
|
this.onInit++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSelect(value: null | undefined) {
|
||||||
|
this.form.get('select').setValue(value);
|
||||||
|
}
|
||||||
|
|
||||||
ionViewWillEnter() {
|
ionViewWillEnter() {
|
||||||
if (this.onInit !== 1) {
|
if (this.onInit !== 1) {
|
||||||
throw new Error('ngOnInit was not called');
|
throw new Error('ngOnInit was not called');
|
||||||
|
@ -5,7 +5,6 @@ test.describe('datetime: color', () => {
|
|||||||
test('should not have visual regressions', async ({ page }) => {
|
test('should not have visual regressions', async ({ page }) => {
|
||||||
await page.goto('/src/components/datetime/test/color');
|
await page.goto('/src/components/datetime/test/color');
|
||||||
|
|
||||||
const colorSelect = page.locator('ion-select');
|
|
||||||
const datetime = page.locator('ion-datetime');
|
const datetime = page.locator('ion-datetime');
|
||||||
|
|
||||||
await page.evaluate(() => document.body.classList.toggle('dark'));
|
await page.evaluate(() => document.body.classList.toggle('dark'));
|
||||||
@ -19,7 +18,9 @@ test.describe('datetime: color', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await page.evaluate(() => document.body.classList.toggle('dark'));
|
await page.evaluate(() => document.body.classList.toggle('dark'));
|
||||||
await colorSelect.evaluate((el: HTMLIonSelectElement) => (el.value = 'danger'));
|
await datetime.evaluateAll((els: HTMLIonDatetimeElement[]) => {
|
||||||
|
els.forEach((el) => (el.color = 'danger'));
|
||||||
|
});
|
||||||
await page.waitForChanges();
|
await page.waitForChanges();
|
||||||
|
|
||||||
expect(await datetime.first().screenshot()).toMatchSnapshot(
|
expect(await datetime.first().screenshot()).toMatchSnapshot(
|
||||||
|
@ -40,7 +40,6 @@ import type { SelectCompareFn } from './select-interface';
|
|||||||
export class Select implements ComponentInterface {
|
export class Select implements ComponentInterface {
|
||||||
private inputId = `ion-sel-${selectIds++}`;
|
private inputId = `ion-sel-${selectIds++}`;
|
||||||
private overlay?: OverlaySelect;
|
private overlay?: OverlaySelect;
|
||||||
private didInit = false;
|
|
||||||
private focusEl?: HTMLButtonElement;
|
private focusEl?: HTMLButtonElement;
|
||||||
private mutationO?: MutationObserver;
|
private mutationO?: MutationObserver;
|
||||||
|
|
||||||
@ -150,12 +149,11 @@ export class Select implements ComponentInterface {
|
|||||||
@Watch('value')
|
@Watch('value')
|
||||||
valueChanged() {
|
valueChanged() {
|
||||||
this.emitStyle();
|
this.emitStyle();
|
||||||
// TODO: FW-1160 - Remove the `didInit` property when ionChange behavior is changed in v7.
|
}
|
||||||
if (this.didInit) {
|
|
||||||
this.ionChange.emit({
|
private setValue(value?: any | null) {
|
||||||
value: this.value,
|
this.value = value;
|
||||||
});
|
this.ionChange.emit({ value });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
@ -174,10 +172,6 @@ export class Select implements ComponentInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidLoad() {
|
|
||||||
this.didInit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the select overlay. The overlay is either an alert, action sheet, or popover,
|
* Open the select overlay. The overlay is either an alert, action sheet, or popover,
|
||||||
* depending on the `interface` property on the `ion-select`.
|
* depending on the `interface` property on the `ion-select`.
|
||||||
@ -279,7 +273,7 @@ export class Select implements ComponentInterface {
|
|||||||
text: option.textContent,
|
text: option.textContent,
|
||||||
cssClass: optClass,
|
cssClass: optClass,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
this.value = value;
|
this.setValue(value);
|
||||||
},
|
},
|
||||||
} as ActionSheetButton;
|
} as ActionSheetButton;
|
||||||
});
|
});
|
||||||
@ -340,7 +334,7 @@ export class Select implements ComponentInterface {
|
|||||||
checked: isOptionSelected(selectValue, value, this.compareWith),
|
checked: isOptionSelected(selectValue, value, this.compareWith),
|
||||||
disabled: option.disabled,
|
disabled: option.disabled,
|
||||||
handler: (selected: any) => {
|
handler: (selected: any) => {
|
||||||
this.value = selected;
|
this.setValue(selected);
|
||||||
if (!this.multiple) {
|
if (!this.multiple) {
|
||||||
this.close();
|
this.close();
|
||||||
}
|
}
|
||||||
@ -463,7 +457,7 @@ export class Select implements ComponentInterface {
|
|||||||
{
|
{
|
||||||
text: this.okText,
|
text: this.okText,
|
||||||
handler: (selectedValues: any) => {
|
handler: (selectedValues: any) => {
|
||||||
this.value = selectedValues;
|
this.setValue(selectedValues);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -95,3 +95,99 @@ test.describe('select: basic', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe('select: ionChange', () => {
|
||||||
|
test.beforeEach(({ skip }) => {
|
||||||
|
skip.rtl();
|
||||||
|
skip.mode('ios', 'ionChange has a consistent behavior across modes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fire ionChange when confirming a value from an alert', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<ion-select interface="alert">
|
||||||
|
<ion-select-option value="apple">Apple</ion-select-option>
|
||||||
|
<ion-select-option value="banana">Banana</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ionAlertDidPresent = await page.spyOnEvent('ionAlertDidPresent');
|
||||||
|
const ionChange = await page.spyOnEvent('ionChange');
|
||||||
|
const select = page.locator('ion-select');
|
||||||
|
|
||||||
|
await select.click();
|
||||||
|
await ionAlertDidPresent.next();
|
||||||
|
|
||||||
|
const alert = page.locator('ion-alert');
|
||||||
|
const radioButtons = alert.locator('.alert-radio-button');
|
||||||
|
const confirmButton = alert.locator('.alert-button:not(.alert-button-role-cancel)');
|
||||||
|
|
||||||
|
await radioButtons.nth(0).click();
|
||||||
|
await confirmButton.click();
|
||||||
|
|
||||||
|
await ionChange.next();
|
||||||
|
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fire ionChange when confirming a value from a popover', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<ion-select interface="popover">
|
||||||
|
<ion-select-option value="apple">Apple</ion-select-option>
|
||||||
|
<ion-select-option value="banana">Banana</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
|
||||||
|
const ionChange = await page.spyOnEvent('ionChange');
|
||||||
|
const select = page.locator('ion-select');
|
||||||
|
|
||||||
|
await select.click();
|
||||||
|
await ionPopoverDidPresent.next();
|
||||||
|
|
||||||
|
const popover = page.locator('ion-popover');
|
||||||
|
const radioButtons = popover.locator('ion-radio');
|
||||||
|
|
||||||
|
await radioButtons.nth(0).click();
|
||||||
|
|
||||||
|
await ionChange.next();
|
||||||
|
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should fire ionChange when confirming a value from an action sheet', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<ion-select interface="action-sheet">
|
||||||
|
<ion-select-option value="apple">Apple</ion-select-option>
|
||||||
|
<ion-select-option value="banana">Banana</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ionActionSheetDidPresent = await page.spyOnEvent('ionActionSheetDidPresent');
|
||||||
|
const ionChange = await page.spyOnEvent('ionChange');
|
||||||
|
const select = page.locator('ion-select');
|
||||||
|
|
||||||
|
await select.click();
|
||||||
|
await ionActionSheetDidPresent.next();
|
||||||
|
|
||||||
|
const actionSheet = page.locator('ion-action-sheet');
|
||||||
|
const buttons = actionSheet.locator('.action-sheet-button');
|
||||||
|
|
||||||
|
await buttons.nth(0).click();
|
||||||
|
|
||||||
|
await ionChange.next();
|
||||||
|
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not fire when programmatically setting a valid value', async ({ page }) => {
|
||||||
|
await page.setContent(`
|
||||||
|
<ion-select>
|
||||||
|
<ion-select-option value="apple">Apple</ion-select-option>
|
||||||
|
<ion-select-option value="banana">Banana</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const ionChange = await page.spyOnEvent('ionChange');
|
||||||
|
const select = page.locator('ion-select');
|
||||||
|
|
||||||
|
await select.evaluate((el: HTMLIonSelectElement) => (el.value = 'banana'));
|
||||||
|
await expect(ionChange).not.toHaveReceivedEvent();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Reference in New Issue
Block a user