Files
ionic-framework/core/src/components/select/select.tsx
2023-01-25 13:30:03 -05:00

723 lines
19 KiB
TypeScript

import type { ComponentInterface, EventEmitter } from '@stencil/core';
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
import { getIonMode } from '../../global/ionic-global';
import type {
ActionSheetButton,
ActionSheetOptions,
AlertInput,
AlertOptions,
CssClassMap,
OverlaySelect,
PopoverOptions,
SelectChangeEventDetail,
SelectInterface,
SelectPopoverOption,
StyleEventDetail,
} from '../../interface';
import { findItemLabel, focusElement, getAriaLabel, renderHiddenInput } from '../../utils/helpers';
import { actionSheetController, alertController, popoverController } from '../../utils/overlays';
import { hostContext } from '../../utils/theme';
import { watchForOptions } from '../../utils/watch-options';
import type { SelectCompareFn } from './select-interface';
// TODO(FW-2832): types
/**
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
*
* @part placeholder - The text displayed in the select when there is no value.
* @part text - The displayed value of the select.
* @part icon - The select icon container.
*/
@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 focusEl?: 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;
/**
* 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 [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.
*/
@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<SelectChangeEventDetail>;
/**
* Emitted when the selection is cancelled.
*/
@Event() ionCancel!: EventEmitter<void>;
/**
* Emitted when the overlay is dismissed.
*/
@Event() ionDismiss!: EventEmitter<void>;
/**
* Emitted when the select has focus.
*/
@Event() ionFocus!: EventEmitter<void>;
/**
* Emitted when the select loses focus.
*/
@Event() ionBlur!: EventEmitter<void>;
/**
* Emitted when the styles change.
* @internal
*/
@Event() ionStyle!: EventEmitter<StyleEventDetail>;
@Watch('disabled')
@Watch('placeholder')
@Watch('isExpanded')
styleChanged() {
this.emitStyle();
}
@Watch('value')
valueChanged() {
this.emitStyle();
// TODO: FW-1160 - Remove the `didInit` property when ionChange behavior is changed in v7.
if (this.didInit) {
this.ionChange.emit({
value: this.value,
});
}
}
async connectedCallback() {
this.updateOverlayOptions();
this.emitStyle();
this.mutationO = watchForOptions<HTMLIonSelectOptionElement>(this.el, 'ion-select-option', async () => {
this.updateOverlayOptions();
/**
* We need to re-render the component
* because one of the new ion-select-option
* elements may match the value. In this case,
* the rendered selected text should be updated.
*/
forceUpdate(this);
});
}
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<any> {
if (this.disabled || this.isExpanded) {
return undefined;
}
this.isExpanded = true;
const overlay = (this.overlay = await this.createOverlay(event));
overlay.onDidDismiss().then(() => {
this.overlay = undefined;
this.isExpanded = false;
this.ionDismiss.emit();
this.setFocus();
});
await overlay.present();
// focus selected option for popovers
if (this.interface === 'popover') {
let indexOfSelected = this.childOpts.map((o) => o.value).indexOf(this.value);
indexOfSelected = indexOfSelected > -1 ? indexOfSelected : 0; // default to first option if nothing selected
const selectedItem = overlay.querySelector<HTMLElement>(
`.select-interface-option:nth-child(${indexOfSelected + 1})`
);
if (selectedItem) {
focusElement(selectedItem);
/**
* Browsers such as Firefox do not
* correctly delegate focus when manually
* focusing an element with delegatesFocus.
* We work around this by manually focusing
* the interactive element.
* ion-radio and ion-checkbox are the only
* elements that ion-select-popover uses, so
* we only need to worry about those two components
* when focusing.
*/
const interactiveEl = selectedItem.querySelector<HTMLElement>('ion-radio, ion-checkbox');
if (interactiveEl) {
interactiveEl.focus();
}
}
}
return overlay;
}
private createOverlay(ev?: UIEvent): Promise<OverlaySelect> {
let selectInterface = this.interface;
if (selectInterface === 'action-sheet' && 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 "${selectInterface}" without passing an event. Using the "alert" interface instead.`
);
selectInterface = 'alert';
}
if (selectInterface === 'action-sheet') {
return this.openActionSheet();
}
if (selectInterface === 'popover') {
return this.openPopover(ev!);
}
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);
// Remove hydrated before copying over classes
const copyClasses = Array.from(option.classList)
.filter((cls) => cls !== 'hydrated')
.join(' ');
const optClass = `${OPTION_CLASS} ${copyClasses}`;
return {
role: isOptionSelected(selectValue, value, this.compareWith) ? 'selected' : '',
text: option.textContent,
cssClass: optClass,
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[] {
const alertInputs = 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 {
type: inputType,
cssClass: optClass,
label: option.textContent || '',
value,
checked: isOptionSelected(selectValue, value, this.compareWith),
disabled: option.disabled,
};
});
return alertInputs;
}
private createPopoverOptions(data: HTMLIonSelectOptionElement[], selectValue: any): SelectPopoverOption[] {
const popoverOptions = 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) => {
this.value = selected;
if (!this.multiple) {
this.close();
}
},
};
});
return popoverOptions;
}
private async openPopover(ev: UIEvent) {
const interfaceOptions = this.interfaceOptions;
const mode = getIonMode(this);
const showBackdrop = mode === 'md' ? false : true;
const multiple = this.multiple;
const value = this.value;
let event: Event | CustomEvent = ev;
let size = 'auto';
const item = this.el.closest('ion-item');
// If the select is inside of an item containing a floating
// or stacked label then the popover should take up the
// full width of the item when it presents
if (item && (item.classList.contains('item-label-floating') || item.classList.contains('item-label-stacked'))) {
event = {
...ev,
detail: {
ionShadowTarget: item,
},
};
size = 'cover';
}
const popoverOpts: PopoverOptions = {
mode,
event,
alignment: 'center',
size,
showBackdrop,
...interfaceOptions,
component: 'ion-select-popover',
cssClass: ['select-popover', interfaceOptions.cssClass],
componentProps: {
header: interfaceOptions.header,
subHeader: interfaceOptions.subHeader,
message: interfaceOptions.message,
multiple,
value,
options: this.createPopoverOptions(this.childOpts, value),
},
};
/**
* Workaround for Stencil to autodefine
* ion-select-popover and ion-popover when
* using Custom Elements build.
*/
// eslint-disable-next-line
if (false) {
// eslint-disable-next-line
// @ts-ignore
document.createElement('ion-select-popover');
document.createElement('ion-popover');
}
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],
};
/**
* Workaround for Stencil to autodefine
* ion-action-sheet when
* using Custom Elements build.
*/
// eslint-disable-next-line
if (false) {
// eslint-disable-next-line
// @ts-ignore
document.createElement('ion-action-sheet');
}
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',
],
};
/**
* Workaround for Stencil to autodefine
* ion-alert when
* using Custom Elements build.
*/
// eslint-disable-next-line
if (false) {
// eslint-disable-next-line
// @ts-ignore
document.createElement('ion-alert');
}
return alertController.create(alertOpts);
}
/**
* Close the select interface.
*/
private close(): Promise<boolean> {
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.focusEl) {
this.focusEl.focus();
}
}
private emitStyle() {
this.ionStyle.emit({
interactive: true,
'interactive-disabled': this.disabled,
select: true,
'select-disabled': this.disabled,
'has-placeholder': this.placeholder !== undefined,
'has-value': this.hasValue(),
'has-focus': this.isExpanded,
});
}
private onClick = (ev: UIEvent) => {
this.setFocus();
this.open(ev);
};
private onFocus = () => {
this.ionFocus.emit();
};
private onBlur = () => {
this.ionBlur.emit();
};
render() {
const { disabled, el, inputId, isExpanded, name, placeholder, value } = this;
const mode = getIonMode(this);
const { labelText, labelId } = getAriaLabel(el, inputId);
renderHiddenInput(true, el, name, parseValue(value), disabled);
const displayValue = this.getText();
let addPlaceholderClass = false;
let selectText = displayValue;
if (selectText === '' && placeholder !== undefined) {
selectText = placeholder;
addPlaceholderClass = true;
}
const selectTextClasses: CssClassMap = {
'select-text': true,
'select-placeholder': addPlaceholderClass,
};
const textPart = addPlaceholderClass ? 'placeholder' : 'text';
// If there is a label then we need to concatenate it with the
// current value (or placeholder) and a comma so it separates
// nicely when the screen reader announces it, otherwise just
// announce the value / placeholder
const displayLabel =
labelText !== undefined ? (selectText !== '' ? `${selectText}, ${labelText}` : labelText) : selectText;
return (
<Host
onClick={this.onClick}
role="button"
aria-haspopup="listbox"
aria-disabled={disabled ? 'true' : null}
aria-label={displayLabel}
class={{
[mode]: true,
'in-item': hostContext('ion-item', el),
'select-disabled': disabled,
'select-expanded': isExpanded,
}}
>
<div aria-hidden="true" class={selectTextClasses} part={textPart}>
{selectText}
</div>
<div class="select-icon" role="presentation" part="icon">
<div class="select-icon-inner"></div>
</div>
<label id={labelId}>{displayLabel}</label>
<button
type="button"
disabled={disabled}
id={inputId}
aria-labelledby={labelId}
aria-haspopup="listbox"
aria-expanded={`${isExpanded}`}
onFocus={this.onFocus}
onBlur={this.onBlur}
ref={(focusEl) => (this.focusEl = focusEl)}
></button>
</Host>
);
}
}
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(value, getOptionValue(opt), compareWith);
});
return selectOpt ? selectOpt.textContent : null;
};
let selectIds = 0;
const OPTION_CLASS = 'select-interface-option';