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>
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
1
core/src/components/select-modal/select-modal.ios.scss
Normal file
@ -0,0 +1 @@
|
||||
@import "./select-modal";
|
||||
30
core/src/components/select-modal/select-modal.md.scss
Normal 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)};
|
||||
}
|
||||
3
core/src/components/select-modal/select-modal.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
height: 100%;
|
||||
}
|
||||
161
core/src/components/select-modal/select-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
40
core/src/components/select-modal/test/basic/index.html
Normal 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>
|
||||
101
core/src/components/select-modal/test/basic/select-modal.e2e.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
73
core/src/components/select-modal/test/fixtures.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||