diff --git a/core/api.txt b/core/api.txt index de7c3531974..3aff52a1cfc 100644 --- a/core/api.txt +++ b/core/api.txt @@ -1614,7 +1614,7 @@ ion-select,prop,compareWith,((currentValue: any, compareValue: any) => boolean) ion-select,prop,disabled,boolean,false,false,false ion-select,prop,expandedIcon,string | undefined,undefined,false,false ion-select,prop,fill,"outline" | "solid" | undefined,undefined,false,false -ion-select,prop,interface,"action-sheet" | "alert" | "popover",'alert',false,false +ion-select,prop,interface,"action-sheet" | "alert" | "modal" | "popover",'alert',false,false ion-select,prop,interfaceOptions,any,{},false,false ion-select,prop,justify,"end" | "space-between" | "start" | undefined,undefined,false,false ion-select,prop,label,string | undefined,undefined,false,false @@ -1672,6 +1672,11 @@ ion-select,part,label ion-select,part,placeholder ion-select,part,text +ion-select-modal,scoped +ion-select-modal,prop,header,string | undefined,undefined,false,false +ion-select-modal,prop,multiple,boolean | undefined,undefined,false,false +ion-select-modal,prop,options,SelectModalOption[],[],false,false + ion-select-option,shadow ion-select-option,prop,disabled,boolean,false,false,false ion-select-option,prop,value,any,undefined,false,false diff --git a/core/src/components.d.ts b/core/src/components.d.ts index f0cd3d081ad..48ceedfa8a1 100644 --- a/core/src/components.d.ts +++ b/core/src/components.d.ts @@ -35,6 +35,7 @@ import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./compone import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; import { SegmentButtonLayout } from "./components/segment-button/segment-button-interface"; import { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; +import { SelectModalOption } from "./components/select-modal/select-modal-interface"; import { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; import { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; import { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; @@ -70,6 +71,7 @@ export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./compone export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface"; export { SegmentButtonLayout } from "./components/segment-button/segment-button-interface"; export { SelectChangeEventDetail, SelectCompareFn, SelectInterface } from "./components/select/select-interface"; +export { SelectModalOption } from "./components/select-modal/select-modal-interface"; export { SelectPopoverOption } from "./components/select-popover/select-popover-interface"; export { TabBarChangedEventDetail, TabButtonClickEventDetail, TabButtonLayout } from "./components/tab-bar/tab-bar-interface"; export { TextareaChangeEventDetail, TextareaInputEventDetail } from "./components/textarea/textarea-interface"; @@ -639,6 +641,7 @@ export namespace Components { * The name of the control, which is submitted with the form data. */ "name": string; + "setFocus": () => Promise; /** * The value of the checkbox does not mean if it's checked or not, use the `checked` property for that. The value of a checkbox is analogous to the value of an ``, it's only used when the checkbox participates in a native `
`. */ @@ -2279,7 +2282,7 @@ export namespace Components { */ "name": string; "setButtonTabindex": (value: number) => Promise; - "setFocus": (ev: globalThis.Event) => Promise; + "setFocus": (ev?: globalThis.Event) => Promise; /** * the value of the radio. */ @@ -2741,11 +2744,11 @@ export namespace Components { */ "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`. */ "interface": SelectInterface; /** - * 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 create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface. + * 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), 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. */ "interfaceOptions": any; /** @@ -2802,6 +2805,11 @@ export namespace Components { */ "value"?: any | null; } + interface IonSelectModal { + "header"?: string; + "multiple"?: boolean; + "options": SelectModalOption[]; + } interface IonSelectOption { /** * If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons. @@ -4434,6 +4442,12 @@ declare global { prototype: HTMLIonSelectElement; new (): HTMLIonSelectElement; }; + interface HTMLIonSelectModalElement extends Components.IonSelectModal, HTMLStencilElement { + } + var HTMLIonSelectModalElement: { + prototype: HTMLIonSelectModalElement; + new (): HTMLIonSelectModalElement; + }; interface HTMLIonSelectOptionElement extends Components.IonSelectOption, HTMLStencilElement { } var HTMLIonSelectOptionElement: { @@ -4722,6 +4736,7 @@ declare global { "ion-segment": HTMLIonSegmentElement; "ion-segment-button": HTMLIonSegmentButtonElement; "ion-select": HTMLIonSelectElement; + "ion-select-modal": HTMLIonSelectModalElement; "ion-select-option": HTMLIonSelectOptionElement; "ion-select-popover": HTMLIonSelectPopoverElement; "ion-skeleton-text": HTMLIonSkeletonTextElement; @@ -7497,11 +7512,11 @@ declare namespace LocalJSX { */ "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`. */ "interface"?: SelectInterface; /** - * 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 create options for each interface. Note: `interfaceOptions` will not override `inputs` or `buttons` with the `alert` interface. + * 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), 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. */ "interfaceOptions"?: any; /** @@ -7577,6 +7592,11 @@ declare namespace LocalJSX { */ "value"?: any | null; } + interface IonSelectModal { + "header"?: string; + "multiple"?: boolean; + "options"?: SelectModalOption[]; + } interface IonSelectOption { /** * If `true`, the user cannot interact with the select option. This property does not apply when `interface="action-sheet"` as `ion-action-sheet` does not allow for disabled buttons. @@ -8163,6 +8183,7 @@ declare namespace LocalJSX { "ion-segment": IonSegment; "ion-segment-button": IonSegmentButton; "ion-select": IonSelect; + "ion-select-modal": IonSelectModal; "ion-select-option": IonSelectOption; "ion-select-popover": IonSelectPopover; "ion-skeleton-text": IonSkeletonText; @@ -8262,6 +8283,7 @@ declare module "@stencil/core" { "ion-segment": LocalJSX.IonSegment & JSXBase.HTMLAttributes; "ion-segment-button": LocalJSX.IonSegmentButton & JSXBase.HTMLAttributes; "ion-select": LocalJSX.IonSelect & JSXBase.HTMLAttributes; + "ion-select-modal": LocalJSX.IonSelectModal & JSXBase.HTMLAttributes; "ion-select-option": LocalJSX.IonSelectOption & JSXBase.HTMLAttributes; "ion-select-popover": LocalJSX.IonSelectPopover & JSXBase.HTMLAttributes; "ion-skeleton-text": LocalJSX.IonSkeletonText & JSXBase.HTMLAttributes; diff --git a/core/src/components/checkbox/checkbox.tsx b/core/src/components/checkbox/checkbox.tsx index 1eca97eeda7..78206b9fd16 100644 --- a/core/src/components/checkbox/checkbox.tsx +++ b/core/src/components/checkbox/checkbox.tsx @@ -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(); } diff --git a/core/src/components/radio-group/radio-group.tsx b/core/src/components/radio-group/radio-group.tsx index c46156a2c95..5d6036bc842 100644 --- a/core/src/components/radio-group/radio-group.tsx +++ b/core/src/components/radio-group/radio-group.tsx @@ -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); } diff --git a/core/src/components/radio/radio.tsx b/core/src/components/radio/radio.tsx index e037d110f95..bb343c8c858 100644 --- a/core/src/components/radio/radio.tsx +++ b/core/src/components/radio/radio.tsx @@ -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(); } diff --git a/core/src/components/select-modal/select-modal-interface.ts b/core/src/components/select-modal/select-modal-interface.ts new file mode 100644 index 00000000000..2005400cb82 --- /dev/null +++ b/core/src/components/select-modal/select-modal-interface.ts @@ -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 }; +} diff --git a/core/src/components/select-modal/select-modal.ios.scss b/core/src/components/select-modal/select-modal.ios.scss new file mode 100644 index 00000000000..7e60b693fcf --- /dev/null +++ b/core/src/components/select-modal/select-modal.ios.scss @@ -0,0 +1 @@ +@import "./select-modal"; diff --git a/core/src/components/select-modal/select-modal.md.scss b/core/src/components/select-modal/select-modal.md.scss new file mode 100644 index 00000000000..511c8d786ab --- /dev/null +++ b/core/src/components/select-modal/select-modal.md.scss @@ -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)}; +} diff --git a/core/src/components/select-modal/select-modal.scss b/core/src/components/select-modal/select-modal.scss new file mode 100644 index 00000000000..683ae23faeb --- /dev/null +++ b/core/src/components/select-modal/select-modal.scss @@ -0,0 +1,3 @@ +:host { + height: 100%; +} diff --git a/core/src/components/select-modal/select-modal.tsx b/core/src/components/select-modal/select-modal.tsx new file mode 100644 index 00000000000..ad927860439 --- /dev/null +++ b/core/src/components/select-modal/select-modal.tsx @@ -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 ( + this.callOptionHandler(ev)}> + {this.options.map((option) => ( + + 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} + + + ))} + + ); + } + + private renderCheckboxOptions() { + return this.options.map((option) => ( + + { + this.setChecked(ev); + this.callOptionHandler(ev); + // TODO FW-4784 + forceUpdate(this); + }} + > + {option.text} + + + )); + } + + render() { + return ( + + + + {this.header !== undefined && {this.header}} + + + this.closeModal()}>Close + + + + + {this.multiple === true ? this.renderCheckboxOptions() : this.renderRadioOptions()} + + + ); + } +} diff --git a/core/src/components/select-modal/test/basic/index.html b/core/src/components/select-modal/test/basic/index.html new file mode 100644 index 00000000000..8ddb3b54f0b --- /dev/null +++ b/core/src/components/select-modal/test/basic/index.html @@ -0,0 +1,40 @@ + + + + + Select - Modal + + + + + + + + + + + + + Select Modal - Basic + + + + + + + + + + + + + diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts b/core/src/components/select-modal/test/basic/select-modal.e2e.ts new file mode 100644 index 00000000000..126082712c3 --- /dev/null +++ b/core/src/components/select-modal/test/basic/select-modal.e2e.ts @@ -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'); + }); + }); +}); diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..72f5453ea82 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..b69898dc6dc Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..05425333c5f Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..7613855bd6d Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..0d71098bb5c Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..c190809cd81 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..084a1cd1dc4 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..73ad7120073 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..aaf5d78f034 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-ios-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Chrome-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Chrome-linux.png new file mode 100644 index 00000000000..8482f64dbaa Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Chrome-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Firefox-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Firefox-linux.png new file mode 100644 index 00000000000..29c3709ef58 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Firefox-linux.png differ diff --git a/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Safari-linux.png b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Safari-linux.png new file mode 100644 index 00000000000..b55b7bc77e5 Binary files /dev/null and b/core/src/components/select-modal/test/basic/select-modal.e2e.ts-snapshots/select-modal-multiple-diff-md-ltr-Mobile-Safari-linux.png differ diff --git a/core/src/components/select-modal/test/fixtures.ts b/core/src/components/select-modal/test/fixtures.ts new file mode 100644 index 00000000000..2058848aa84 --- /dev/null +++ b/core/src/components/select-modal/test/fixtures.ts @@ -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( + ` + + + + + `, + 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); + } +} diff --git a/core/src/components/select/select-interface.ts b/core/src/components/select/select-interface.ts index 4fa2a18828c..8e65377a825 100644 --- a/core/src/components/select/select-interface.ts +++ b/core/src/components/select/select-interface.ts @@ -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; diff --git a/core/src/components/select/select.tsx b/core/src/components/select/select.tsx index bdd8cc0b2e5..3b4ef84f26f 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -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( @@ -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('ion-radio, ion-checkbox'); + const interactiveEl = selectedItem.querySelector('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( '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. */ diff --git a/core/src/components/select/test/basic/index.html b/core/src/components/select/test/basic/index.html index 421dd4505fe..c958d08f909 100644 --- a/core/src/components/select/test/basic/index.html +++ b/core/src/components/select/test/basic/index.html @@ -51,6 +51,14 @@ Pears + + + + Apples + Oranges + Pears + + @@ -76,6 +84,15 @@ Honey Badger + + + + Bird + Cat + Dog + Honey Badger + + @@ -124,6 +141,16 @@ Onions + + + + Pepperoni + Bacon + Extra Cheese + Mushrooms + Onions + + @@ -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; diff --git a/core/src/components/select/test/basic/select.e2e.ts b/core/src/components/select/test/basic/select.e2e.ts index d44c8ca7619..35747728495 100644 --- a/core/src/components/select/test/basic/select.e2e.ts +++ b/core/src/components/select/test/basic/select.e2e.ts @@ -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(); + }); + }); }); }); diff --git a/core/src/utils/overlays-interface.ts b/core/src/utils/overlays-interface.ts index 4a37fca71ab..63f70baac8b 100644 --- a/core/src/utils/overlays-interface.ts +++ b/core/src/utils/overlays-interface.ts @@ -46,4 +46,8 @@ export interface HTMLIonOverlayElement extends HTMLStencilElement { present: () => Promise; } -export type OverlaySelect = HTMLIonActionSheetElement | HTMLIonAlertElement | HTMLIonPopoverElement; +export type OverlaySelect = + | HTMLIonActionSheetElement + | HTMLIonAlertElement + | HTMLIonPopoverElement + | HTMLIonModalElement; diff --git a/packages/angular/src/directives/proxies-list.ts b/packages/angular/src/directives/proxies-list.ts index 1874d0bfe2d..93d0cc13ad9 100644 --- a/packages/angular/src/directives/proxies-list.ts +++ b/packages/angular/src/directives/proxies-list.ts @@ -70,6 +70,7 @@ export const DIRECTIVES = [ d.IonSegment, d.IonSegmentButton, d.IonSelect, + d.IonSelectModal, d.IonSelectOption, d.IonSkeletonText, d.IonSpinner, diff --git a/packages/angular/src/directives/proxies.ts b/packages/angular/src/directives/proxies.ts index 89558cb46a1..e384f56c531 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2057,6 +2057,28 @@ This event will not emit when programmatically setting the `value` property. } +@ProxyCmp({ + inputs: ['header', 'multiple', 'options'] +}) +@Component({ + selector: 'ion-select-modal', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['header', 'multiple', 'options'], +}) +export class IonSelectModal { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonSelectModal extends Components.IonSelectModal {} + + @ProxyCmp({ inputs: ['disabled', 'value'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index b0426e56968..5fac940a661 100644 --- a/packages/angular/standalone/src/directives/proxies.ts +++ b/packages/angular/standalone/src/directives/proxies.ts @@ -65,6 +65,7 @@ import { defineCustomElement as defineIonReorderGroup } from '@ionic/core/compon import { defineCustomElement as defineIonRippleEffect } from '@ionic/core/components/ion-ripple-effect.js'; import { defineCustomElement as defineIonRow } from '@ionic/core/components/ion-row.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; +import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js'; @@ -1841,6 +1842,30 @@ export class IonSegmentButton { export declare interface IonSegmentButton extends Components.IonSegmentButton {} +@ProxyCmp({ + defineCustomElementFn: defineIonSelectModal, + inputs: ['header', 'multiple', 'options'] +}) +@Component({ + selector: 'ion-select-modal', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['header', 'multiple', 'options'], + standalone: true +}) +export class IonSelectModal { + protected el: HTMLElement; + constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) { + c.detach(); + this.el = r.nativeElement; + } +} + + +export declare interface IonSelectModal extends Components.IonSelectModal {} + + @ProxyCmp({ defineCustomElementFn: defineIonSelectOption, inputs: ['disabled', 'value'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 05800f38773..14f81d30ebd 100644 --- a/packages/react/src/components/proxies.ts +++ b/packages/react/src/components/proxies.ts @@ -62,6 +62,7 @@ import { defineCustomElement as defineIonSearchbar } from '@ionic/core/component import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js'; +import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js'; @@ -131,6 +132,7 @@ export const IonSearchbar = /*@__PURE__*/createReactComponent('ion-segment', undefined, undefined, defineIonSegment); export const IonSegmentButton = /*@__PURE__*/createReactComponent('ion-segment-button', undefined, undefined, defineIonSegmentButton); export const IonSelect = /*@__PURE__*/createReactComponent('ion-select', undefined, undefined, defineIonSelect); +export const IonSelectModal = /*@__PURE__*/createReactComponent('ion-select-modal', undefined, undefined, defineIonSelectModal); export const IonSelectOption = /*@__PURE__*/createReactComponent('ion-select-option', undefined, undefined, defineIonSelectOption); export const IonSkeletonText = /*@__PURE__*/createReactComponent('ion-skeleton-text', undefined, undefined, defineIonSkeletonText); export const IonSpinner = /*@__PURE__*/createReactComponent('ion-spinner', undefined, undefined, defineIonSpinner); diff --git a/packages/vue/src/proxies.ts b/packages/vue/src/proxies.ts index 347673a7461..0d93a32c46d 100644 --- a/packages/vue/src/proxies.ts +++ b/packages/vue/src/proxies.ts @@ -68,6 +68,7 @@ import { defineCustomElement as defineIonSearchbar } from '@ionic/core/component import { defineCustomElement as defineIonSegment } from '@ionic/core/components/ion-segment.js'; import { defineCustomElement as defineIonSegmentButton } from '@ionic/core/components/ion-segment-button.js'; import { defineCustomElement as defineIonSelect } from '@ionic/core/components/ion-select.js'; +import { defineCustomElement as defineIonSelectModal } from '@ionic/core/components/ion-select-modal.js'; import { defineCustomElement as defineIonSelectOption } from '@ionic/core/components/ion-select-option.js'; import { defineCustomElement as defineIonSkeletonText } from '@ionic/core/components/ion-skeleton-text.js'; import { defineCustomElement as defineIonSpinner } from '@ionic/core/components/ion-spinner.js'; @@ -782,6 +783,13 @@ export const IonSelect = /*@__PURE__*/ defineContainer('ion-select-modal', defineIonSelectModal, [ + 'header', + 'multiple', + 'options' +]); + + export const IonSelectOption = /*@__PURE__*/ defineContainer('ion-select-option', defineIonSelectOption, [ 'disabled', 'value'