import { Component, ComponentInterface, Element, Event, EventEmitter, Host, Method, Prop, State, Watch, h } from '@stencil/core'; import { getIonMode } from '../../global/ionic-global'; import { ActionSheetButton, ActionSheetOptions, AlertInput, AlertOptions, CssClassMap, OverlaySelect, PopoverOptions, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, StyleEventDetail } from '../../interface'; import { findItemLabel, renderHiddenInput } from '../../utils/helpers'; import { actionSheetController, alertController, popoverController } from '../../utils/overlays'; import { hostContext } from '../../utils/theme'; import { watchForOptions } from '../../utils/watch-options'; import { SelectCompareFn } from './select-interface'; /** * @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use. * * @TODOpart placeholder - The text displayed in the select when there is no value. * @TODOpart text - The displayed value of the select. * @TODOpart icon - The select icon container. * @TODOpart icon-inner - The select icon. */ @Component({ tag: 'ion-select', styleUrls: { ios: 'select.ios.scss', md: 'select.md.scss' }, shadow: true }) export class Select implements ComponentInterface { private inputId = `ion-sel-${selectIds++}`; private overlay?: OverlaySelect; private didInit = false; private buttonEl?: HTMLButtonElement; private mutationO?: MutationObserver; @Element() el!: HTMLIonSelectElement; @State() isExpanded = false; /** * If `true`, the user cannot interact with the select. */ @Prop() disabled = false; /** * The text to display on the cancel button. */ @Prop() cancelText = 'Cancel'; /** * The text to display on the ok button. */ @Prop() okText = 'OK'; /** * The text to display when the select is empty. */ @Prop() placeholder?: string | null; /** * The name of the control, which is submitted with the form data. */ @Prop() name: string = this.inputId; /** * The text to display instead of the selected option's value. */ @Prop() selectedText?: string | null; /** * If `true`, the select can accept multiple values. */ @Prop() multiple = false; /** * The interface the select should use: `action-sheet`, `popover` or `alert`. */ @Prop() interface: SelectInterface = 'alert'; /** * Any additional options that the `alert`, `action-sheet` or `popover` interface * can take. See the [AlertController API docs](../../alert/AlertController/#create), the * [ActionSheetController API docs](../../action-sheet/ActionSheetController/#create) and the * [PopoverController API docs](../../popover/PopoverController/#create) for the * create options for each interface. */ @Prop() interfaceOptions: any = {}; /** * A property name or function used to compare object values */ @Prop() compareWith?: string | SelectCompareFn | null; /** * the value of the select. */ @Prop({ mutable: true }) value?: any | null; /** * Emitted when the value has changed. */ @Event() ionChange!: EventEmitter; /** * Emitted when the selection is cancelled. */ @Event() ionCancel!: EventEmitter; /** * Emitted when the select has focus. */ @Event() ionFocus!: EventEmitter; /** * Emitted when the select loses focus. */ @Event() ionBlur!: EventEmitter; /** * Emitted when the styles change. * @internal */ @Event() ionStyle!: EventEmitter; @Watch('disabled') @Watch('placeholder') disabledChanged() { this.emitStyle(); } @Watch('value') valueChanged() { this.emitStyle(); if (this.didInit) { this.ionChange.emit({ value: this.value, }); } } async connectedCallback() { this.updateOverlayOptions(); this.emitStyle(); this.mutationO = watchForOptions(this.el, 'ion-select-option', async () => { this.updateOverlayOptions(); }); } disconnectedCallback() { if (this.mutationO) { this.mutationO.disconnect(); this.mutationO = undefined; } } componentDidLoad() { this.didInit = true; } /** * Open the select overlay. The overlay is either an alert, action sheet, or popover, * depending on the `interface` property on the `ion-select`. * * @param event The user interface event that called the open. */ @Method() async open(event?: UIEvent): Promise { if (this.disabled || this.isExpanded) { return undefined; } const overlay = this.overlay = await this.createOverlay(event); this.isExpanded = true; overlay.onDidDismiss().then(() => { this.overlay = undefined; this.isExpanded = false; this.setFocus(); }); await overlay.present(); return overlay; } private createOverlay(ev?: UIEvent): Promise { let selectInterface = this.interface; if ((selectInterface === 'action-sheet' || selectInterface === 'popover') && this.multiple) { console.warn(`Select interface cannot be "${selectInterface}" with a multi-value select. Using the "alert" interface instead.`); selectInterface = 'alert'; } if (selectInterface === 'popover' && !ev) { console.warn('Select interface cannot be a "popover" without passing an event. Using the "alert" interface instead.'); selectInterface = 'alert'; } if (selectInterface === 'popover') { return this.openPopover(ev!); } if (selectInterface === 'action-sheet') { return this.openActionSheet(); } return this.openAlert(); } private updateOverlayOptions(): void { const overlay = (this.overlay as any); if (!overlay) { return; } const childOpts = this.childOpts; const value = this.value; switch (this.interface) { case 'action-sheet': overlay.buttons = this.createActionSheetButtons(childOpts, value); break; case 'popover': const popover = overlay.querySelector('ion-select-popover'); if (popover) { popover.options = this.createPopoverOptions(childOpts, value); } break; case 'alert': const inputType = (this.multiple ? 'checkbox' : 'radio'); overlay.inputs = this.createAlertInputs(childOpts, inputType, value); break; } } private createActionSheetButtons(data: HTMLIonSelectOptionElement[], selectValue: any): ActionSheetButton[] { const actionSheetButtons = data.map(option => { const value = getOptionValue(option); return { role: (isOptionSelected(value, selectValue, this.compareWith) ? 'selected' : ''), text: option.textContent, handler: () => { this.value = value; } } as ActionSheetButton; }); // Add "cancel" button actionSheetButtons.push({ text: this.cancelText, role: 'cancel', handler: () => { this.ionCancel.emit(); } }); return actionSheetButtons; } private createAlertInputs(data: HTMLIonSelectOptionElement[], inputType: 'checkbox' | 'radio', selectValue: any): AlertInput[] { return data.map(o => { const value = getOptionValue(o); return { type: inputType, label: o.textContent || '', value, checked: isOptionSelected(value, selectValue, this.compareWith), disabled: o.disabled }; }); } private createPopoverOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] { return data.map(o => { const value = getOptionValue(o); return { text: o.textContent || '', value, checked: isOptionSelected(value, selectValue, this.compareWith), disabled: o.disabled, handler: () => { this.value = value; this.close(); } }; }); } private async openPopover(ev: UIEvent) { const interfaceOptions = this.interfaceOptions; const mode = getIonMode(this); const value = this.value; const popoverOpts: PopoverOptions = { mode, ...interfaceOptions, component: 'ion-select-popover', cssClass: ['select-popover', interfaceOptions.cssClass], event: ev, componentProps: { header: interfaceOptions.header, subHeader: interfaceOptions.subHeader, message: interfaceOptions.message, value, options: this.createPopoverOptions(this.childOpts, value) } }; return popoverController.create(popoverOpts); } private async openActionSheet() { const mode = getIonMode(this); const interfaceOptions = this.interfaceOptions; const actionSheetOpts: ActionSheetOptions = { mode, ...interfaceOptions, buttons: this.createActionSheetButtons(this.childOpts, this.value), cssClass: ['select-action-sheet', interfaceOptions.cssClass] }; return actionSheetController.create(actionSheetOpts); } private async openAlert() { const label = this.getLabel(); const labelText = (label) ? label.textContent : null; const interfaceOptions = this.interfaceOptions; const inputType = (this.multiple ? 'checkbox' : 'radio'); const mode = getIonMode(this); const alertOpts: AlertOptions = { mode, ...interfaceOptions, header: interfaceOptions.header ? interfaceOptions.header : labelText, inputs: this.createAlertInputs(this.childOpts, inputType, this.value), buttons: [ { text: this.cancelText, role: 'cancel', handler: () => { this.ionCancel.emit(); } }, { text: this.okText, handler: (selectedValues: any) => { this.value = selectedValues; } } ], cssClass: ['select-alert', interfaceOptions.cssClass, (this.multiple ? 'multiple-select-alert' : 'single-select-alert')] }; return alertController.create(alertOpts); } /** * Close the select interface. */ private close(): Promise { // TODO check !this.overlay || !this.isFocus() if (!this.overlay) { return Promise.resolve(false); } return this.overlay.dismiss(); } private getLabel() { return findItemLabel(this.el); } private hasValue(): boolean { return this.getText() !== ''; } private get childOpts() { return Array.from(this.el.querySelectorAll('ion-select-option')); } private getText(): string { const selectedText = this.selectedText; if (selectedText != null && selectedText !== '') { return selectedText; } return generateText(this.childOpts, this.value, this.compareWith); } private setFocus() { if (this.buttonEl) { this.buttonEl.focus(); } } private emitStyle() { this.ionStyle.emit({ 'interactive': true, 'select': true, 'has-placeholder': this.placeholder != null, 'has-value': this.hasValue(), 'interactive-disabled': this.disabled, 'select-disabled': this.disabled }); } private onClick = (ev: UIEvent) => { this.setFocus(); this.open(ev); } private onFocus = () => { this.ionFocus.emit(); } private onBlur = () => { this.ionBlur.emit(); } render() { const { placeholder, name, disabled, isExpanded, value, el } = this; const mode = getIonMode(this); const labelId = this.inputId + '-lbl'; const label = findItemLabel(el); if (label) { label.id = labelId; } let addPlaceholderClass = false; let selectText = this.getText(); if (selectText === '' && placeholder != null) { selectText = placeholder; addPlaceholderClass = true; } renderHiddenInput(true, el, name, parseValue(value), disabled); const selectTextClasses: CssClassMap = { 'select-text': true, 'select-placeholder': addPlaceholderClass }; const textPart = addPlaceholderClass ? 'placeholder' : 'text'; return (
{selectText}
); } } const isOptionSelected = (currentValue: any[] | any, compareValue: any, compareWith?: string | SelectCompareFn | null) => { if (currentValue === undefined) { return false; } if (Array.isArray(currentValue)) { return currentValue.some(val => compareOptions(val, compareValue, compareWith)); } else { return compareOptions(currentValue, compareValue, compareWith); } }; const getOptionValue = (el: HTMLIonSelectOptionElement) => { const value = el.value; return (value === undefined) ? el.textContent || '' : value; }; const parseValue = (value: any) => { if (value == null) { return undefined; } if (Array.isArray(value)) { return value.join(','); } return value.toString(); }; const compareOptions = (currentValue: any, compareValue: any, compareWith?: string | SelectCompareFn | null): boolean => { if (typeof compareWith === 'function') { return compareWith(currentValue, compareValue); } else if (typeof compareWith === 'string') { return currentValue[compareWith] === compareValue[compareWith]; } else { return Array.isArray(compareValue) ? compareValue.includes(currentValue) : currentValue === compareValue; } }; const generateText = (opts: HTMLIonSelectOptionElement[], value: any | any[], compareWith?: string | SelectCompareFn | null) => { if (value === undefined) { return ''; } if (Array.isArray(value)) { return value .map(v => textForValue(opts, v, compareWith)) .filter(opt => opt !== null) .join(', '); } else { return textForValue(opts, value, compareWith) || ''; } }; const textForValue = (opts: HTMLIonSelectOptionElement[], value: any, compareWith?: string | SelectCompareFn | null): string | null => { const selectOpt = opts.find(opt => { return compareOptions(getOptionValue(opt), value, compareWith); }); return selectOpt ? selectOpt.textContent : null; }; let selectIds = 0;