feat(select): add modal as interface (#29972)

Issue number: resolves internal

---------

<!-- Please do not submit updates to dependencies unless it fixes an
issue. -->

<!-- Please try to limit your pull request to one type (bugfix, feature,
etc). Submit multiple pull requests if needed. -->

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying. -->

Select only offers `alert`, `action-sheet`, and `popover` as interfaces

## What is the new behavior?
<!-- Please describe the behavior or changes that are being added by
this PR. -->

Adds `modal` as an interface option for `ion-select`

## Does this introduce a breaking change?

- [ ] Yes
- [x] No

<!--
  If this introduces a breaking change:
1. Describe the impact and migration path for existing applications
below.
  2. Update the BREAKING.md file with the breaking change.
3. Add "BREAKING CHANGE: [...]" to the commit description when merging.
See
https://github.com/ionic-team/ionic-framework/blob/main/docs/CONTRIBUTING.md#footer
for more information.
-->


## Other information

<!-- Any other information that is important to this PR such as
screenshots of how the component looks before and after the change. -->

---------

Co-authored-by: Brandy Carney <brandyscarney@users.noreply.github.com>
This commit is contained in:
Tanner Reits
2024-10-31 10:01:32 -04:00
committed by Tanner Reits
parent 0fdcb32ce0
commit 3628ea875a
35 changed files with 651 additions and 33 deletions

View File

@ -1,5 +1,5 @@
import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Prop, h } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, h } from '@stencil/core';
import type { Attributes } from '@utils/helpers';
import { inheritAriaAttributes, renderHiddenInput } from '@utils/helpers';
import { createColorClasses, hostContext } from '@utils/theme';
@ -121,7 +121,9 @@ export class Checkbox implements ComponentInterface {
};
}
private setFocus() {
/** @internal */
@Method()
async setFocus() {
if (this.focusEl) {
this.focusEl.focus();
}

View File

@ -155,7 +155,9 @@ export class RadioGroup implements ComponentInterface {
@Listen('keydown', { target: 'document' })
onKeydown(ev: KeyboardEvent) {
const inSelectPopover = !!this.el.closest('ion-select-popover');
// We don't want the value to automatically change/emit when the radio group is part of a select interface
// as this will cause the interface to close when navigating through the radio group options
const inSelectInterface = !!this.el.closest('ion-select-popover') || !!this.el.closest('ion-select-modal');
if (ev.target && !this.el.contains(ev.target as HTMLElement)) {
return;
@ -187,7 +189,7 @@ export class RadioGroup implements ComponentInterface {
if (next && radios.includes(next)) {
next.setFocus(ev);
if (!inSelectPopover) {
if (!inSelectInterface) {
this.value = next.value;
this.emitValueChange(ev);
}

View File

@ -126,9 +126,11 @@ export class Radio implements ComponentInterface {
/** @internal */
@Method()
async setFocus(ev: globalThis.Event) {
ev.stopPropagation();
ev.preventDefault();
async setFocus(ev?: globalThis.Event) {
if (ev !== undefined) {
ev.stopPropagation();
ev.preventDefault();
}
this.el.focus();
}

View File

@ -0,0 +1,8 @@
export interface SelectModalOption {
text: string;
value: string;
disabled: boolean;
checked: boolean;
cssClass?: string | string[];
handler?: (value: any) => boolean | void | { [key: string]: any };
}

View File

@ -0,0 +1 @@
@import "./select-modal";

View File

@ -0,0 +1,30 @@
@import "./select-modal";
@import "../../themes/ionic.mixins.scss";
@import "../item/item.md.vars";
ion-list ion-radio::part(container) {
display: none;
}
ion-list ion-radio::part(label) {
@include margin(0);
}
ion-item {
--inner-border-width: 0;
}
.item-radio-checked {
--background: #{ion-color(primary, base, 0.08)};
--background-focused: #{ion-color(primary, base)};
--background-focused-opacity: 0.2;
--background-hover: #{ion-color(primary, base)};
--background-hover-opacity: 0.12;
}
.item-checkbox-checked {
--background-activated: #{$item-md-color};
--background-focused: #{$item-md-color};
--background-hover: #{$item-md-color};
--color: #{ion-color(primary, base)};
}

View File

@ -0,0 +1,3 @@
:host {
height: 100%;
}

View File

@ -0,0 +1,161 @@
import { getIonMode } from '@global/ionic-global';
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Prop, forceUpdate, h } from '@stencil/core';
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 { SelectModalOption } from './select-modal-interface';
@Component({
tag: 'ion-select-modal',
styleUrls: {
ios: 'select-modal.ios.scss',
md: 'select-modal.md.scss',
ionic: 'select-modal.md.scss',
},
scoped: true,
})
export class SelectModal implements ComponentInterface {
@Element() el!: HTMLIonSelectModalElement;
@Prop() header?: string;
@Prop() multiple?: boolean;
@Prop() options: SelectModalOption[] = [];
private closeModal() {
const modal = this.el.closest('ion-modal');
if (modal) {
modal.dismiss();
}
}
private findOptionFromEvent(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const { options } = this;
return options.find((o) => o.value === ev.target.value);
}
private getValues(ev?: CheckboxCustomEvent | RadioGroupCustomEvent): string | string[] | undefined {
const { multiple, options } = this;
if (multiple) {
// this is a modal with checkboxes (multiple value select)
// return an array of all the checked values
return options.filter((o) => o.checked).map((o) => o.value);
}
// this is a modal with radio buttons (single value select)
// return the value that was clicked, otherwise undefined
const option = ev ? this.findOptionFromEvent(ev) : null;
return option ? option.value : undefined;
}
private callOptionHandler(ev: CheckboxCustomEvent | RadioGroupCustomEvent) {
const option = this.findOptionFromEvent(ev);
const values = this.getValues(ev);
if (option?.handler) {
safeCall(option.handler, values);
}
}
private setChecked(ev: CheckboxCustomEvent): void {
const { multiple } = this;
const option = this.findOptionFromEvent(ev);
// this is a modal with checkboxes (multiple value select)
// we need to set the checked value for this option
if (multiple && option) {
option.checked = ev.detail.checked;
}
}
private renderRadioOptions() {
const checked = this.options.filter((o) => o.checked).map((o) => o.value)[0];
return (
<ion-radio-group value={checked} onIonChange={(ev) => this.callOptionHandler(ev)}>
{this.options.map((option) => (
<ion-item
class={{
// TODO FW-4784
'item-radio-checked': option.value === checked,
...getClassMap(option.cssClass),
}}
>
<ion-radio
value={option.value}
disabled={option.disabled}
justify="start"
labelPlacement="end"
onClick={() => this.closeModal()}
onKeyUp={(ev) => {
if (ev.key === ' ') {
/**
* Selecting a radio option with keyboard navigation,
* either through the Enter or Space keys, should
* dismiss the modal.
*/
this.closeModal();
}
}}
>
{option.text}
</ion-radio>
</ion-item>
))}
</ion-radio-group>
);
}
private renderCheckboxOptions() {
return this.options.map((option) => (
<ion-item
class={{
// TODO FW-4784
'item-checkbox-checked': option.checked,
...getClassMap(option.cssClass),
}}
>
<ion-checkbox
value={option.value}
disabled={option.disabled}
checked={option.checked}
justify="start"
labelPlacement="end"
onIonChange={(ev) => {
this.setChecked(ev);
this.callOptionHandler(ev);
// TODO FW-4784
forceUpdate(this);
}}
>
{option.text}
</ion-checkbox>
</ion-item>
));
}
render() {
return (
<Host class={getIonMode(this)}>
<ion-header>
<ion-toolbar>
{this.header !== undefined && <ion-title>{this.header}</ion-title>}
<ion-buttons slot="end">
<ion-button onClick={() => this.closeModal()}>Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-list>{this.multiple === true ? this.renderCheckboxOptions() : this.renderRadioOptions()}</ion-list>
</ion-content>
</Host>
);
}
}

View File

@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8" />
<title>Select - Modal</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 Modal - Basic</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-modal is-open="true">
<ion-select-modal multiple="false"></ion-select-modal>
</ion-modal>
</ion-content>
</ion-app>
<script>
const selectModal = document.querySelector('ion-select-modal');
selectModal.options = [
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];
</script>
</body>
</html>

View File

@ -0,0 +1,101 @@
import { expect } from '@playwright/test';
import { configs, test } from '@utils/test/playwright';
import type { SelectModalOption } from '../../select-modal-interface';
import { SelectModalPage } from '../fixtures';
const options: SelectModalOption[] = [
{ value: 'apple', text: 'Apple', disabled: false, checked: false },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];
const checkedOptions: SelectModalOption[] = [
{ value: 'apple', text: 'Apple', disabled: false, checked: true },
{ value: 'banana', text: 'Banana', disabled: false, checked: false },
];
/**
* This behavior does not vary across modes/directions.
*/
configs({ modes: ['md'], directions: ['ltr'] }).forEach(({ title, config }) => {
test.describe(title('select-modal: basic'), () => {
test.beforeEach(({ browserName }) => {
test.skip(browserName === 'webkit', 'ROU-5437');
});
test.describe('single selection', () => {
let selectModalPage: SelectModalPage;
test.beforeEach(async ({ page }) => {
selectModalPage = new SelectModalPage(page);
});
test('clicking an unselected option should dismiss the modal', async () => {
await selectModalPage.setup(config, options, false);
await selectModalPage.clickOption('apple');
await selectModalPage.ionModalDidDismiss.next();
await expect(selectModalPage.modal).not.toBeVisible();
});
test('clicking a selected option should dismiss the modal', async () => {
await selectModalPage.setup(config, checkedOptions, false);
await selectModalPage.clickOption('apple');
await selectModalPage.ionModalDidDismiss.next();
await expect(selectModalPage.modal).not.toBeVisible();
});
test('pressing Space on an unselected option should dismiss the modal', async () => {
await selectModalPage.setup(config, options, false);
await selectModalPage.pressSpaceOnOption('apple');
await selectModalPage.ionModalDidDismiss.next();
await expect(selectModalPage.modal).not.toBeVisible();
});
test('pressing Space on a selected option should dismiss the modal', async ({ browserName }) => {
test.skip(browserName === 'firefox', 'Same behavior as ROU-5437');
await selectModalPage.setup(config, checkedOptions, false);
await selectModalPage.pressSpaceOnOption('apple');
await selectModalPage.ionModalDidDismiss.next();
await expect(selectModalPage.modal).not.toBeVisible();
});
test('clicking the close button should dismiss the modal', async () => {
await selectModalPage.setup(config, options, false);
const closeButton = selectModalPage.modal.locator('ion-header ion-toolbar ion-button');
await closeButton.click();
await selectModalPage.ionModalDidDismiss.next();
await expect(selectModalPage.modal).not.toBeVisible();
});
});
});
});
/**
* This behavior does not vary across directions.
* The components used inside of `ion-select-modal`
* do have RTL logic, but those are tested in their
* respective component test files.
*/
configs({ directions: ['ltr'] }).forEach(({ title, screenshot, config }) => {
test.describe(title('select-modal: rendering'), () => {
let selectModalPage: SelectModalPage;
test.beforeEach(async ({ page }) => {
selectModalPage = new SelectModalPage(page);
});
test('should not have visual regressions with single selection', async () => {
await selectModalPage.setup(config, checkedOptions, false);
await selectModalPage.screenshot(screenshot, 'select-modal-diff');
});
test('should not have visual regressions with multiple selection', async () => {
await selectModalPage.setup(config, checkedOptions, true);
await selectModalPage.screenshot(screenshot, 'select-modal-multiple-diff');
});
});
});

View File

@ -0,0 +1,73 @@
import { expect } from '@playwright/test';
import type { E2EPage, E2ELocator, EventSpy, E2EPageOptions, ScreenshotFn } from '@utils/test/playwright';
import type { SelectModalOption } from '../select-modal-interface';
export class SelectModalPage {
private page: E2EPage;
private multiple?: boolean;
private options: SelectModalOption[] = [];
// Locators
modal!: E2ELocator;
selectModal!: E2ELocator;
// Event spies
ionModalDidDismiss!: EventSpy;
constructor(page: E2EPage) {
this.page = page;
}
async setup(config: E2EPageOptions, options: SelectModalOption[], multiple = false) {
const { page } = this;
await page.setContent(
`
<ion-modal>
<ion-select-modal></ion-select-modal>
</ion-modal>
<script>
const selectModal = document.querySelector('ion-select-modal');
selectModal.options = ${JSON.stringify(options)};
selectModal.multiple = ${multiple};
</script>
`,
config
);
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
this.ionModalDidDismiss = await page.spyOnEvent('ionModalDidDismiss');
this.modal = page.locator('ion-modal');
this.selectModal = page.locator('ion-select-modal');
this.multiple = multiple;
this.options = options;
await this.modal.evaluate((modal: HTMLIonModalElement) => modal.present());
await ionModalDidPresent.next();
}
async screenshot(screenshot: ScreenshotFn, name: string) {
await expect(this.selectModal).toHaveScreenshot(screenshot(name));
}
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, selectModal } = this;
const selector = multiple ? 'ion-checkbox' : 'ion-radio';
const index = this.options.findIndex((o) => o.value === value);
return selectModal.locator(selector).nth(index);
}
}

View File

@ -1,4 +1,4 @@
export type SelectInterface = 'action-sheet' | 'popover' | 'alert';
export type SelectInterface = 'action-sheet' | 'popover' | 'alert' | 'modal';
export type SelectCompareFn = (currentValue: any, compareValue: any) => boolean;

View File

@ -4,7 +4,7 @@ import type { NotchController } from '@utils/forms';
import { compareOptions, createNotchController, isOptionSelected } from '@utils/forms';
import { focusVisibleElement, renderHiddenInput, inheritAttributes } from '@utils/helpers';
import type { Attributes } from '@utils/helpers';
import { actionSheetController, alertController, popoverController } from '@utils/overlays';
import { actionSheetController, alertController, popoverController, modalController } from '@utils/overlays';
import type { OverlaySelect } from '@utils/overlays-interface';
import { isRTL } from '@utils/rtl';
import { createColorClasses, hostContext } from '@utils/theme';
@ -19,6 +19,7 @@ import type {
CssClassMap,
PopoverOptions,
StyleEventDetail,
ModalOptions,
} from '../../interface';
import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
import type { AlertInput } from '../alert/alert-interface';
@ -98,15 +99,15 @@ export class Select implements ComponentInterface {
@Prop() fill?: 'outline' | 'solid';
/**
* The interface the select should use: `action-sheet`, `popover` or `alert`.
* The interface the select should use: `action-sheet`, `popover`, `alert`, or `modal`.
*/
@Prop() interface: SelectInterface = 'alert';
/**
* Any additional options that the `alert`, `action-sheet` or `popover` interface
* can take. See the [ion-alert docs](./alert), the
* [ion-action-sheet docs](./action-sheet) and the
* [ion-popover docs](./popover) for the
* [ion-action-sheet docs](./action-sheet), the
* [ion-popover docs](./popover), and the [ion-modal docs](./modal) for the
* create options for each interface.
*
* Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface.
@ -318,9 +319,9 @@ export class Select implements ComponentInterface {
await overlay.present();
// focus selected option for popovers
if (this.interface === 'popover') {
const indexOfSelected = this.childOpts.map((o) => o.value).indexOf(this.value);
// focus selected option for popovers and modals
if (this.interface === 'popover' || this.interface === 'modal') {
const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value);
if (indexOfSelected > -1) {
const selectedItem = overlay.querySelector<HTMLElement>(
@ -328,8 +329,6 @@ export class Select implements ComponentInterface {
);
if (selectedItem) {
focusVisibleElement(selectedItem);
/**
* Browsers such as Firefox do not
* correctly delegate focus when manually
@ -341,10 +340,17 @@ export class Select implements ComponentInterface {
* we only need to worry about those two components
* when focusing.
*/
const interactiveEl = selectedItem.querySelector<HTMLElement>('ion-radio, ion-checkbox');
const interactiveEl = selectedItem.querySelector<HTMLElement>('ion-radio, ion-checkbox') as
| HTMLIonRadioElement
| HTMLIonCheckboxElement
| null;
if (interactiveEl) {
interactiveEl.focus();
// Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
// and removing `ion-focused` style
interactiveEl.setFocus();
}
focusVisibleElement(selectedItem);
}
} else {
/**
@ -352,14 +358,18 @@ export class Select implements ComponentInterface {
*/
const firstEnabledOption = overlay.querySelector<HTMLElement>(
'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)'
);
if (firstEnabledOption) {
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
) as HTMLIonRadioElement | HTMLIonCheckboxElement | null;
if (firstEnabledOption) {
/**
* Focus the option for the same reason as we do above.
*
* Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
* and removing `ion-focused` style
*/
firstEnabledOption.focus();
firstEnabledOption.setFocus();
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
}
}
}
@ -389,6 +399,9 @@ export class Select implements ComponentInterface {
if (selectInterface === 'popover') {
return this.openPopover(ev!);
}
if (selectInterface === 'modal') {
return this.openModal();
}
return this.openAlert();
}
@ -406,7 +419,13 @@ export class Select implements ComponentInterface {
case 'popover':
const popover = overlay.querySelector('ion-select-popover');
if (popover) {
popover.options = this.createPopoverOptions(childOpts, value);
popover.options = this.createOverlaySelectOptions(childOpts, value);
}
break;
case 'modal':
const modal = overlay.querySelector('ion-select-modal');
if (modal) {
modal.options = this.createOverlaySelectOptions(childOpts, value);
}
break;
case 'alert':
@ -475,7 +494,7 @@ export class Select implements ComponentInterface {
return alertInputs;
}
private createPopoverOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
private createOverlaySelectOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
const popoverOptions = data.map((option) => {
const value = getOptionValue(option);
@ -553,7 +572,7 @@ export class Select implements ComponentInterface {
message: interfaceOptions.message,
multiple,
value,
options: this.createPopoverOptions(this.childOpts, value),
options: this.createOverlaySelectOptions(this.childOpts, value),
},
};
@ -647,6 +666,40 @@ export class Select implements ComponentInterface {
return alertController.create(alertOpts);
}
private openModal() {
const { multiple, value, interfaceOptions } = this;
const mode = getIonMode(this);
const modalOpts: ModalOptions = {
...interfaceOptions,
mode,
cssClass: ['select-modal', interfaceOptions.cssClass],
component: 'ion-select-modal',
componentProps: {
header: interfaceOptions.header,
multiple,
value,
options: this.createOverlaySelectOptions(this.childOpts, value),
},
};
/**
* Workaround for Stencil to autodefine
* ion-select-modal and ion-modal when
* using Custom Elements build.
*/
// eslint-disable-next-line
if (false) {
// eslint-disable-next-line
// @ts-ignore
document.createElement('ion-select-modal');
document.createElement('ion-modal');
}
return modalController.create(modalOpts);
}
/**
* Close the select interface.
*/

View File

@ -51,6 +51,14 @@
<ion-select-option value="pears">Pears</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select label="Modal" placeholder="Select one" interface="modal">
<ion-select-option value="apples">Apples</ion-select-option>
<ion-select-option value="oranges">Oranges</ion-select-option>
<ion-select-option value="pears">Pears</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<ion-list>
@ -76,6 +84,15 @@
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
</ion-select>
</ion-item>
<ion-item>
<ion-select label="Modal" multiple="true" interface="modal">
<ion-select-option value="bird">Bird</ion-select-option>
<ion-select-option value="cat">Cat</ion-select-option>
<ion-select-option value="dog">Dog</ion-select-option>
<ion-select-option value="honeybadger">Honey Badger</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
<ion-list>
@ -124,6 +141,16 @@
<ion-select-option value="onions">Onions</ion-select-option>
</ion-select>
</ion-item>
<ion-item color="secondary">
<ion-select label="Modal Sheet" id="customModalSelect" interface="modal" placeholder="Select One">
<ion-select-option value="pepperoni">Pepperoni</ion-select-option>
<ion-select-option value="bacon">Bacon</ion-select-option>
<ion-select-option value="xcheese">Extra Cheese</ion-select-option>
<ion-select-option value="mushrooms">Mushrooms</ion-select-option>
<ion-select-option value="onions">Onions</ion-select-option>
</ion-select>
</ion-item>
</ion-list>
</ion-content>
@ -152,6 +179,14 @@
message: '$1.50 charge for every topping',
};
customActionSheetSelect.interfaceOptions = customActionSheetOptions;
var customModalSelect = document.getElementById('customModalSelect');
var customModalSheetOptions = {
header: 'Pizza Toppings',
breakpoints: [0.5],
initialBreakpoint: 0.5,
};
customModalSelect.interfaceOptions = customModalSheetOptions;
</script>
</ion-app>
</body>

View File

@ -58,6 +58,24 @@ configs({ directions: ['ltr'] }).forEach(({ title, config }) => {
await expect(popover).toBeVisible();
});
});
test.describe('select: modal', () => {
test('it should open a modal select', async ({ page }) => {
const ionModalDidPresent = await page.spyOnEvent('ionModalDidPresent');
await page.click('#customModalSelect');
await ionModalDidPresent.next();
const modal = page.locator('ion-modal');
// select has no value, so first option should be focused by default
const modalOption1 = modal.locator('.select-interface-option:first-of-type ion-radio');
await expect(modalOption1).toBeFocused();
await expect(modal).toBeVisible();
});
});
});
});