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

@ -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);
}
}