diff --git a/core/src/components.d.ts b/core/src/components.d.ts index f8c88b745b..c3f96e606f 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"; @@ -3267,6 +3269,12 @@ export namespace Components { */ "value"?: any | null; } + interface IonSelectModal { + "confirmHandler"?: (value?: any | null) => void; + "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. @@ -5015,6 +5023,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: { @@ -5303,6 +5317,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; @@ -8626,6 +8641,12 @@ declare namespace LocalJSX { */ "value"?: any | null; } + interface IonSelectModal { + "confirmHandler"?: (value?: any | null) => void; + "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. @@ -9328,6 +9349,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; @@ -9427,6 +9449,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/select-modal/select-modal-interface.ts b/core/src/components/select-modal/select-modal-interface.ts new file mode 100644 index 0000000000..2005400cb8 --- /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.scss b/core/src/components/select-modal/select-modal.scss new file mode 100644 index 0000000000..e69de29bb2 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 0000000000..c0150c5870 --- /dev/null +++ b/core/src/components/select-modal/select-modal.tsx @@ -0,0 +1,186 @@ +import { getIonTheme } 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.scss', + md: 'select-modal.scss', + ionic: 'select-modal.scss', + }, + scoped: true, +}) +export class SelectModal implements ComponentInterface { + @Element() el!: HTMLIonSelectModalElement; + + @Prop() header?: string; + + @Prop() multiple?: boolean; + + // TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button) + @Prop() confirmHandler?: (value?: any | null) => void; + + @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 popover 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 popover 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 popover 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() { + const theme = getIonTheme(this); + + return ( + + {this.header !== undefined && ( + + + {this.header} + + + )} + + {this.multiple === true ? this.renderCheckboxOptions() : this.renderRadioOptions()} + + {/* TODO(ROU-11272): Design did not provide designs for how this component should work for multi-select. + * `ion-select-popover` automatically posts data back to the select component, but this feels like an incorrect pattern for modals. + * We'll need to chat with design to figure out the desired UI/UX here. + */} + {this.multiple === true && ( + + + + { + this.confirmHandler?.(this.getValues()); + this.closeModal(); + }} + > + Done + + + + + )} + + ); + } +} diff --git a/core/src/components/select/select-interface.ts b/core/src/components/select/select-interface.ts index 4fa2a18828..8e65377a82 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 f3d5c09833..6afccc7c06 100644 --- a/core/src/components/select/select.tsx +++ b/core/src/components/select/select.tsx @@ -5,7 +5,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'; @@ -21,6 +21,8 @@ import type { CssClassMap, PopoverOptions, StyleEventDetail, + ModalOptions, + SelectModalOption, } from '../../interface'; import type { ActionSheetButton } from '../action-sheet/action-sheet-interface'; import type { AlertInput } from '../alert/alert-interface'; @@ -102,15 +104,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. @@ -393,6 +395,9 @@ export class Select implements ComponentInterface { if (selectInterface === 'popover') { return this.openPopover(ev!); } + if (selectInterface === 'modal') { + return this.openModal(); + } return this.openAlert(); } @@ -413,6 +418,12 @@ export class Select implements ComponentInterface { popover.options = this.createPopoverOptions(childOpts, value); } break; + case 'modal': + const modal = overlay.querySelector('ion-select-modal'); + if (modal) { + modal.options = this.createPopoverOptions(childOpts, value); + } + break; case 'alert': const inputType = this.multiple ? 'checkbox' : 'radio'; overlay.inputs = this.createAlertInputs(childOpts, inputType, value); @@ -507,6 +518,36 @@ export class Select implements ComponentInterface { return popoverOptions; } + // TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button). + // In that case, modal and popover could use the same utility to construct options. + private createModalOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectModalOption[] { + const modalOptions = data.map((option) => { + const value = getOptionValue(option); + + // Remove hydrated before copying over classes + const copyClasses = Array.from(option.classList) + .filter((cls) => cls !== 'hydrated') + .join(' '); + const optClass = `${OPTION_CLASS} ${copyClasses}`; + + return { + text: option.textContent || '', + cssClass: optClass, + value, + checked: isOptionSelected(selectValue, value, this.compareWith), + disabled: option.disabled, + handler: (selected: any) => { + if (!this.multiple) { + this.setValue(selected); + this.close(); + } + }, + }; + }); + + return modalOptions; + } + private async openPopover(ev: UIEvent) { const { fill, labelPlacement } = this; const interfaceOptions = this.interfaceOptions; @@ -651,6 +692,47 @@ export class Select implements ComponentInterface { return alertController.create(alertOpts); } + private openModal() { + const { multiple, value, interfaceOptions } = this; + const theme = getIonTheme(this); + + const modalOpts: ModalOptions = { + // TODO(ROU-11272): If OS only wants to use the sheet modal style, we can set default breakpoints for the modal. + // We could also set them contingent on the theme if this is an interface we want to support outside of ODC + + ...interfaceOptions, + theme, + + cssClass: ['select-modal', interfaceOptions.cssClass], + component: 'ion-select-modal', + componentProps: { + header: interfaceOptions.header, + multiple, + value, + // TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button). + // In that case, modal and popover could use the same utility to construct options. + options: this.createModalOptions(this.childOpts, value), + // TODO(ROU-11272): Not needed if we follow popover's behavior for multi-select (i.e. no confirmation button) + confirmHandler: this.setValue.bind(this), + }, + }; + + /** + * 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-mdoal'); + 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 421dd4505f..c958d08f90 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/utils/overlays-interface.ts b/core/src/utils/overlays-interface.ts index d9f8a9e7ff..06650cd20f 100644 --- a/core/src/utils/overlays-interface.ts +++ b/core/src/utils/overlays-interface.ts @@ -77,4 +77,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 1874d0bfe2..93d0cc13ad 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 a6377d16c0..06066c033f 100644 --- a/packages/angular/src/directives/proxies.ts +++ b/packages/angular/src/directives/proxies.ts @@ -2060,6 +2060,28 @@ This event will not emit when programmatically setting the `value` property. } +@ProxyCmp({ + inputs: ['confirmHandler', 'header', 'multiple', 'options'] +}) +@Component({ + selector: 'ion-select-modal', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['confirmHandler', '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', 'mode', 'theme', 'value'] }) diff --git a/packages/angular/standalone/src/directives/proxies.ts b/packages/angular/standalone/src/directives/proxies.ts index 10223039d1..cd9a4b0b4c 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'; @@ -1843,6 +1844,30 @@ export class IonSegmentButton { export declare interface IonSegmentButton extends Components.IonSegmentButton {} +@ProxyCmp({ + defineCustomElementFn: defineIonSelectModal, + inputs: ['confirmHandler', 'header', 'multiple', 'options'] +}) +@Component({ + selector: 'ion-select-modal', + changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property + inputs: ['confirmHandler', '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', 'mode', 'theme', 'value'] diff --git a/packages/react/src/components/proxies.ts b/packages/react/src/components/proxies.ts index 05800f3877..14f81d30eb 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 b24ee1c91c..e0b576ef72 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'; @@ -796,6 +797,14 @@ export const IonSelect = /*@__PURE__*/ defineContainer('ion-select-modal', defineIonSelectModal, [ + 'header', + 'multiple', + 'confirmHandler', + 'options' +]); + + export const IonSelectOption = /*@__PURE__*/ defineContainer('ion-select-option', defineIonSelectOption, [ 'disabled', 'value'