Skip to content

fix(select): emit single ionChange event for popover option selection #26796

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Feb 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion core/src/components/checkbox/checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,9 @@ Example: <ion-checkbox>Label</ion-checkbox>

For checkboxes that do not have a visible label, developers should use "aria-label" so screen readers can announce the purpose of the checkbox.

For checkboxes that do not render the label immediately next to the checkbox, developers may continue to use "ion-label" but must manually associate the label with the checkbox by using "aria-labelledby".`,
For checkboxes that do not render the label immediately next to the checkbox, developers may continue to use "ion-label" but must manually associate the label with the checkbox by using "aria-labelledby".

Developers can use the "legacy" property to continue using the legacy form markup. This property will be removed in an upcoming major release of Ionic where this form control will use the modern form markup.`,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we are good with the wording here, I can follow-up with another PR to apply across all form controls for v7.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good 👍

this.el
);

Expand Down
57 changes: 37 additions & 20 deletions core/src/components/select-popover/select-popover.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Host, Listen, Prop, h } from '@stencil/core';
import { Element, Component, Host, Prop, h } from '@stencil/core';

import { getIonMode } from '../../global/ionic-global';
import { safeCall } from '../../utils/overlays';
import { getClassMap } from '../../utils/theme';
import type { CheckboxCustomEvent } from '../checkbox/checkbox-interface';
import type { RadioGroupCustomEvent } from '../radio-group/radio-group-interface';

import type { SelectPopoverOption } from './select-popover-interface';

// TODO(FW-2832): types

/**
* @internal
*/
Expand All @@ -21,6 +21,7 @@ import type { SelectPopoverOption } from './select-popover-interface';
scoped: true,
})
export class SelectPopover implements ComponentInterface {
@Element() el!: HTMLIonSelectPopoverElement;
/**
* The header text of the popover
*/
Expand All @@ -46,13 +47,7 @@ export class SelectPopover implements ComponentInterface {
*/
@Prop() options: SelectPopoverOption[] = [];

@Listen('ionChange')
onSelect(ev: any) {
this.setChecked(ev);
this.callOptionHandler(ev);
}

private findOptionFromEvent(ev: any) {
private findOptionFromEvent(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const { options } = this;
return options.find((o) => o.value === ev.target.value);
}
Expand All @@ -62,7 +57,7 @@ export class SelectPopover implements ComponentInterface {
* of the selected option(s) and return it in the option
* handler
*/
private callOptionHandler(ev: any) {
private callOptionHandler(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const option = this.findOptionFromEvent(ev);
const values = this.getValues(ev);

Expand All @@ -72,15 +67,17 @@ export class SelectPopover implements ComponentInterface {
}

/**
* This is required when selecting a radio that is already
* selected because it will not trigger the ionChange event
* but we still want to close the popover
* Dismisses the host popover that the `ion-select-popover`
* is rendered within.
*/
private rbClick(ev: any) {
this.callOptionHandler(ev);
private dismissParentPopover() {
const popover = this.el.closest('ion-popover');
if (popover) {
popover.dismiss();
}
}

private setChecked(ev: any): void {
private setChecked(ev: CheckboxCustomEvent): void {
const { multiple } = this;
const option = this.findOptionFromEvent(ev);

Expand All @@ -91,7 +88,7 @@ export class SelectPopover implements ComponentInterface {
}
}

private getValues(ev: any): any | any[] | null {
private getValues(ev: CheckboxCustomEvent | RadioGroupCustomEvent): string | string[] | undefined {
const { multiple, options } = this;

if (multiple) {
Expand Down Expand Up @@ -125,6 +122,11 @@ export class SelectPopover implements ComponentInterface {
value={option.value}
disabled={option.disabled}
checked={option.checked}
legacy={true}
onIonChange={(ev) => {
this.setChecked(ev);
this.callOptionHandler(ev);
}}
></ion-checkbox>
<ion-label>{option.text}</ion-label>
</ion-item>
Expand All @@ -135,11 +137,26 @@ export class SelectPopover implements ComponentInterface {
const checked = options.filter((o) => o.checked).map((o) => o.value)[0];

return (
<ion-radio-group value={checked}>
<ion-radio-group value={checked} onIonChange={(ev) => this.callOptionHandler(ev)}>
{options.map((option) => (
<ion-item class={getClassMap(option.cssClass)}>
<ion-label>{option.text}</ion-label>
<ion-radio value={option.value} disabled={option.disabled} onClick={(ev) => this.rbClick(ev)}></ion-radio>
<ion-radio
value={option.value}
disabled={option.disabled}
legacy={true}
onClick={() => this.dismissParentPopover()}
onKeyUp={(ev) => {
if (ev.key === ' ') {
/**
* Selecting a radio option with keyboard navigation,
* either through the Enter or Space keys, should
* dismiss the popover.
*/
this.dismissParentPopover();
}
}}
></ion-radio>
</ion-item>
))}
</ion-radio-group>
Expand Down
40 changes: 40 additions & 0 deletions core/src/components/select-popover/test/basic/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Select - Popover</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<link href="../../../../../css/ionic.bundle.css" rel="stylesheet" />
<link href="../../../../../scripts/testing/styles.css" rel="stylesheet" />
<script src="../../../../../scripts/testing/scripts.js"></script>
<script nomodule src="../../../../../dist/ionic/ionic.js"></script>
<script type="module" src="../../../../../dist/ionic/ionic.esm.js"></script>
</head>

<body>
<ion-app>
<ion-header>
<ion-toolbar>
<ion-title>Select Popover - Basic</ion-title>
</ion-toolbar>
</ion-header>

<ion-content>
<ion-popover class="select-popover" is-open="true">
<ion-select-popover multiple="false"></ion-select-popover>
</ion-popover>
</ion-content>
</ion-app>

<script>
const selectPopover = document.querySelector('ion-select-popover');
selectPopover.options = [
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];
</script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';

import type { SelectPopoverOption } from '../../select-popover-interface';
import { SelectPopoverPage } from '../fixtures';

const options: SelectPopoverOption[] = [
{ value: 'apple', text: 'Apple', disabled: false, checked: false },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];

const checkedOptions: SelectPopoverOption[] = [
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];

test.describe('select-popover: basic', () => {
test.beforeEach(({ skip, browserName }) => {
skip.rtl();
skip.mode('ios', 'Consistent behavior across modes');
test.skip(browserName === 'webkit', 'https://ionic-cloud.atlassian.net/browse/FW-2979');
});

test.describe('single selection', () => {
let selectPopoverPage: SelectPopoverPage;

test.beforeEach(async ({ page }) => {
selectPopoverPage = new SelectPopoverPage(page);
});

test('clicking an unselected option should dismiss the popover', async () => {
await selectPopoverPage.setup(options, false);

await selectPopoverPage.clickOption('apple');
await selectPopoverPage.ionPopoverDidDismiss.next();
await expect(selectPopoverPage.popover).not.toBeVisible();
});

test('clicking a selected option should dismiss the popover', async () => {
await selectPopoverPage.setup(checkedOptions, false);

await selectPopoverPage.clickOption('apple');
await selectPopoverPage.ionPopoverDidDismiss.next();
await expect(selectPopoverPage.popover).not.toBeVisible();
});

test('pressing Space on an unselected option should dismiss the popover', async () => {
await selectPopoverPage.setup(options, false);

await selectPopoverPage.pressSpaceOnOption('apple');
await selectPopoverPage.ionPopoverDidDismiss.next();
await expect(selectPopoverPage.popover).not.toBeVisible();
});

test('pressing Space on a selected option should dismiss the popover', async ({ browserName }) => {
test.skip(browserName === 'firefox', 'Same behavior as https://ionic-cloud.atlassian.net/browse/FW-2979');

await selectPopoverPage.setup(checkedOptions, false);

await selectPopoverPage.pressSpaceOnOption('apple');
await selectPopoverPage.ionPopoverDidDismiss.next();
await expect(selectPopoverPage.popover).not.toBeVisible();
});
});
});
65 changes: 65 additions & 0 deletions core/src/components/select-popover/test/fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { E2EPage, E2ELocator, EventSpy } from '@utils/test/playwright';

import type { SelectPopoverOption } from '../select-popover-interface';

export class SelectPopoverPage {
private page: E2EPage;
private multiple?: boolean;
private options: SelectPopoverOption[] = [];

// Locators
popover!: E2ELocator;
selectPopover!: E2ELocator;

// Event spies
ionPopoverDidDismiss!: EventSpy;

constructor(page: E2EPage) {
this.page = page;
}

async setup(options: SelectPopoverOption[], multiple = false) {
const { page } = this;

await page.setContent(`
<ion-popover>
<ion-select-popover></ion-select-popover>
</ion-popover>
<script>
const selectPopover = document.querySelector('ion-select-popover');
selectPopover.options = ${JSON.stringify(options)};
selectPopover.multiple = ${multiple};
</script>
`);

const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
this.ionPopoverDidDismiss = await page.spyOnEvent('ionPopoverDidDismiss');

this.popover = page.locator('ion-popover');
this.selectPopover = page.locator('ion-select-popover');
this.multiple = multiple;
this.options = options;

await this.popover.evaluate((popover: HTMLIonPopoverElement) => popover.present());

await ionPopoverDidPresent.next();
}

async clickOption(value: string) {
const option = this.getOption(value);
await option.click();
}

async pressSpaceOnOption(value: string) {
const option = this.getOption(value);
await option.press('Space');
}

private getOption(value: string) {
const { multiple, selectPopover } = this;
const selector = multiple ? 'ion-checkbox' : 'ion-radio';
const index = this.options.findIndex((o) => o.value === value);

return selectPopover.locator(selector).nth(index);
}
}
41 changes: 38 additions & 3 deletions core/src/components/select/test/basic/select.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect } from '@playwright/test';
import { test } from '@utils/test/playwright';
import type { E2ELocator } from '@utils/test/playwright';

test.describe('select: basic', () => {
test.beforeEach(async ({ page }) => {
Expand Down Expand Up @@ -130,6 +131,7 @@ test.describe('select: ionChange', () => {

await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
expect(ionChange).toHaveReceivedEventTimes(1);
});

test('should fire ionChange when confirming a value from a popover', async ({ page }) => {
Expand All @@ -141,8 +143,8 @@ test.describe('select: ionChange', () => {
`);

const ionPopoverDidPresent = await page.spyOnEvent('ionPopoverDidPresent');
const ionChange = await page.spyOnEvent('ionChange');
const select = page.locator('ion-select');
const select = page.locator('ion-select') as E2ELocator;
const ionChange = await select.spyOnEvent('ionChange');

await select.click();
await ionPopoverDidPresent.next();
Expand All @@ -153,7 +155,39 @@ test.describe('select: ionChange', () => {
await radioButtons.nth(0).click();

await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple', event: { isTrusted: true } });
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
expect(ionChange).toHaveReceivedEventTimes(1);
});

test('should fire ionChange when confirming multiple values from a popover', async ({ page }) => {
await page.setContent(`
<ion-select aria-label="Fruit" interface="popover" multiple="true">
<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 select = page.locator('ion-select') as E2ELocator;
const ionChange = await select.spyOnEvent('ionChange');

await select.click();
await ionPopoverDidPresent.next();

const popover = page.locator('ion-popover');
const checkboxes = popover.locator('ion-checkbox');

await checkboxes.nth(0).click();
await ionChange.next();

expect(ionChange).toHaveReceivedEventDetail({ value: ['apple'] });
expect(ionChange).toHaveReceivedEventTimes(1);

await checkboxes.nth(1).click();
await ionChange.next();

expect(ionChange).toHaveReceivedEventDetail({ value: ['apple', 'banana'] });
expect(ionChange).toHaveReceivedEventTimes(2);
});

test('should fire ionChange when confirming a value from an action sheet', async ({ page }) => {
Expand All @@ -178,6 +212,7 @@ test.describe('select: ionChange', () => {

await ionChange.next();
expect(ionChange).toHaveReceivedEventDetail({ value: 'apple' });
expect(ionChange).toHaveReceivedEventTimes(1);
});

test('should not fire when programmatically setting a valid value', async ({ page }) => {
Expand Down
1 change: 1 addition & 0 deletions core/src/utils/test/playwright/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './playwright-page';
export * from './playwright-declarations';
export * from './page/event-spy';
export * from './page/utils';
export * from './drag-element';
export * from './matchers';
export * from './viewports';