Files
2018-09-12 11:38:20 -04:00

526 lines
14 KiB
TypeScript

import { Component, Element, Event, EventEmitter, Listen, Method, Prop, State, Watch } from '@stencil/core';
import { ActionSheetButton, ActionSheetOptions, AlertOptions, CssClassMap, Mode, PopoverOptions, SelectInputChangeEvent, SelectInterface, SelectPopoverOption, StyleEvent } from '../../interface';
import { deferEvent, renderHiddenInput } from '../../utils/helpers';
import { hostContext } from '../../utils/theme';
@Component({
tag: 'ion-select',
styleUrls: {
ios: 'select.ios.scss',
md: 'select.md.scss'
},
shadow: true
})
export class Select {
private childOpts: HTMLIonSelectOptionElement[] = [];
private inputId = `ion-sel-${selectIds++}`;
private labelId?: string;
private overlay?: HTMLIonActionSheetElement | HTMLIonAlertElement | HTMLIonPopoverElement;
@Element() el!: HTMLIonSelectElement;
@Prop({ connect: 'ion-action-sheet-controller' }) actionSheetCtrl!: HTMLIonActionSheetControllerElement;
@Prop({ connect: 'ion-alert-controller' }) alertCtrl!: HTMLIonAlertControllerElement;
@Prop({ connect: 'ion-popover-controller' }) popoverCtrl!: HTMLIonPopoverControllerElement;
@State() isExpanded = false;
@State() keyFocus = false;
@State() text?: string;
/**
* The mode determines which platform styles to use.
* Possible values are: `"ios"` or `"md"`.
*/
@Prop() mode!: Mode;
/**
* If true, the user cannot interact with the select. Defaults to `false`.
*/
@Prop() disabled = false;
/**
* The text to display on the cancel button. Default: `Cancel`.
*/
@Prop() cancelText = 'Cancel';
/**
* The text to display on the ok button. Default: `OK`.
*/
@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;
/**
* If true, the select can accept multiple values.
*/
@Prop() multiple = false;
/**
* The interface the select should use: `action-sheet`, `popover` or `alert`. Default: `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 = {};
/**
* the value of the select.
*/
@Prop({ mutable: true }) value?: any | null;
/**
* Emitted when the value has changed.
*/
@Event() ionChange!: EventEmitter<SelectInputChangeEvent>;
/**
* Emitted when the selection is cancelled.
*/
@Event() ionCancel!: 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.
*/
@Event() ionStyle!: EventEmitter<StyleEvent>;
@Watch('disabled')
disabledChanged() {
this.emitStyle();
}
@Watch('value')
valueChanged() {
// this select value just changed
// double check the select option with this value is checked
if (this.value === undefined) {
// set to undefined
// ensure all that are checked become unchecked
this.childOpts.filter(o => o.selected).forEach(selectOption => {
selectOption.selected = false;
});
this.text = '';
} else {
let hasChecked = false;
const texts: string[] = [];
this.childOpts.forEach(selectOption => {
if ((Array.isArray(this.value) && this.value.includes(selectOption.value)) || (selectOption.value === this.value)) {
if (!selectOption.selected && (this.multiple || !hasChecked)) {
// correct value for this select option
// but this select option isn't checked yet
// and we haven't found a checked yet
// so CHECK IT!
selectOption.selected = true;
} else if (!this.multiple && hasChecked && selectOption.selected) {
// somehow we've got multiple select options
// with the same value, but only one can be checked
selectOption.selected = false;
}
// remember we've got a checked select option button now
hasChecked = true;
} else if (selectOption.selected) {
// this select option doesn't have the correct value
// and it's also checked, so let's uncheck it
selectOption.selected = false;
}
if (selectOption.selected) {
texts.push(selectOption.textContent || '');
}
});
this.text = texts.join(', ');
}
// emit the new value
this.ionChange.emit({
value: this.value,
text: this.text
});
this.emitStyle();
}
@Listen('ionSelectOptionDidLoad')
optLoad(ev: CustomEvent) {
const selectOption = ev.target as HTMLIonSelectOptionElement;
this.childOpts = Array.from(this.el.querySelectorAll('ion-select-option'));
if (this.value != null && (Array.isArray(this.value) && this.value.includes(selectOption.value)) || (selectOption.value === this.value)) {
// this select has a value and this
// option equals the correct select value
// so let's check this select option
selectOption.selected = true;
} else if (Array.isArray(this.value) && this.multiple && selectOption.selected) {
// if the value is an array we need to push the option on
this.value.push(selectOption.value);
} else if (this.value === undefined && selectOption.selected) {
// this select does not have a value
// but this select option is checked, so let's set the
// select's value from the checked select option
this.value = selectOption.value;
} else if (selectOption.selected) {
// if it doesn't match one of the above cases, but the
// select option is still checked, then we need to uncheck it
selectOption.selected = false;
}
}
@Listen('ionSelectOptionDidUnload')
optUnload(ev: CustomEvent) {
const index = this.childOpts.indexOf(ev.target as HTMLIonSelectOptionElement);
if (index > -1) {
this.childOpts.splice(index, 1);
}
}
@Listen('ionSelect')
onSelect(ev: CustomEvent) {
// ionSelect only come from the checked select option
this.childOpts.forEach(selectOption => {
if (selectOption === ev.target) {
this.value = selectOption.value;
} else {
selectOption.selected = false;
}
});
}
componentWillLoad() {
if (!this.value) {
this.value = this.multiple ? [] : undefined;
}
}
componentDidLoad() {
this.ionStyle = deferEvent(this.ionStyle);
const label = this.getLabel();
if (label) {
this.labelId = label.id = this.name + '-lbl';
}
if (this.multiple) {
// there are no values set at this point
// so check to see who should be selected
const checked = this.childOpts.filter(o => o.selected);
(this.value as string[]).length = 0;
checked.forEach(o => {
// doing this instead of map() so we don't
// fire off an unecessary change event
(this.value as string[]).push(o.value);
});
this.text = checked.map(o => o.textContent).join(', ');
} else {
const checked = this.childOpts.find(o => o.selected);
if (checked) {
this.value = checked.value;
this.text = checked.textContent || '';
}
}
this.emitStyle();
}
@Method()
open(ev?: UIEvent): Promise<HTMLIonActionSheetElement | HTMLIonAlertElement | HTMLIonPopoverElement> {
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 getLabel() {
const item = this.el.closest('ion-item');
if (item) {
return item.querySelector('ion-label');
}
return null;
}
private async openPopover(ev: UIEvent) {
const interfaceOptions = this.interfaceOptions;
const popoverOpts: PopoverOptions = {
...interfaceOptions,
component: 'ion-select-popover',
cssClass: ['select-popover', interfaceOptions.cssClass],
event: ev,
componentProps: {
header: interfaceOptions.header,
subHeader: interfaceOptions.subHeader,
message: interfaceOptions.message,
value: this.value,
options: this.childOpts.map(o => {
return {
text: o.textContent,
value: o.value,
checked: o.selected,
disabled: o.disabled,
handler: () => {
this.value = o.value;
// tslint:disable-next-line:no-floating-promises
this.close();
}
} as SelectPopoverOption;
})
}
};
const popover = this.overlay = await this.popoverCtrl.create(popoverOpts);
await popover.present();
this.isExpanded = true;
return popover;
}
private async openActionSheet() {
const actionSheetButtons = this.childOpts.map(option => {
return {
role: (option.selected ? 'selected' : ''),
text: option.textContent,
handler: () => {
this.value = option.value;
}
} as ActionSheetButton;
});
actionSheetButtons.push({
text: this.cancelText,
role: 'cancel',
handler: () => {
this.ionCancel.emit();
}
});
const interfaceOptions = this.interfaceOptions;
const actionSheetOpts: ActionSheetOptions = {
...interfaceOptions,
buttons: actionSheetButtons,
cssClass: ['select-action-sheet', interfaceOptions.cssClass]
};
const actionSheet = this.overlay = await this.actionSheetCtrl.create(actionSheetOpts);
await actionSheet.present();
this.isExpanded = true;
return actionSheet;
}
private async openAlert() {
const label = this.getLabel();
const labelText = (label) ? label.textContent : null;
const interfaceOptions = this.interfaceOptions;
const inputType = (this.multiple ? 'checkbox' : 'radio');
const alertOpts: AlertOptions = {
...interfaceOptions,
header: interfaceOptions.header ? interfaceOptions.header : labelText,
inputs: this.childOpts.map(o => {
return {
type: inputType,
label: o.textContent,
value: o.value,
checked: o.selected,
disabled: o.disabled
};
}),
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')]
};
const alert = this.overlay = await this.alertCtrl.create(alertOpts);
await alert.present();
this.isExpanded = true;
return alert;
}
/**
* Close the select interface.
*/
private close(): Promise<boolean> {
// TODO check !this.overlay || !this.isFocus()
if (!this.overlay) {
return Promise.resolve(false);
}
const overlay = this.overlay;
this.overlay = undefined;
this.isExpanded = false;
return overlay.dismiss();
}
onKeyUp() {
this.keyFocus = true;
}
onFocus() {
this.ionFocus.emit();
}
onBlur() {
this.keyFocus = false;
this.ionBlur.emit();
}
hasValue(): boolean {
if (Array.isArray(this.value)) {
return this.value.length > 0;
}
return (this.value != null && this.value !== undefined && this.value !== '');
}
private emitStyle() {
this.ionStyle.emit({
'interactive': true,
'select': true,
'has-value': this.hasValue(),
'interactive-disabled': this.disabled,
'select-disabled': this.disabled
});
}
hostData() {
return {
class: {
'in-item': hostContext('.item', this.el),
'select-disabled': this.disabled,
'select-key': this.keyFocus
}
};
}
render() {
renderHiddenInput(this.el, this.name, parseValue(this.value), this.disabled);
let addPlaceholderClass = false;
let selectText = this.selectedText || this.text;
if (selectText === undefined && this.placeholder !== undefined) {
selectText = this.placeholder;
addPlaceholderClass = true;
}
const selectTextClasses: CssClassMap = {
'select-text': true,
'select-placeholder': addPlaceholderClass
};
return [
<div
role="textbox"
aria-multiline="false"
class={selectTextClasses}
>
{selectText}
</div>,
<div class="select-icon" role="presentation">
<div class="select-icon-inner"></div>
</div>,
<button
type="button"
role="combobox"
aria-haspopup="dialog"
aria-labelledby={this.labelId}
aria-expanded={this.isExpanded ? 'true' : null}
aria-disabled={this.disabled ? 'true' : null}
onClick={this.open.bind(this)}
onKeyUp={this.onKeyUp.bind(this)}
onFocus={this.onFocus.bind(this)}
onBlur={this.onBlur.bind(this)}
class="select-cover"
>
<slot></slot>
{this.mode === 'md' && <ion-ripple-effect></ion-ripple-effect>}
</button>
];
}
}
function parseValue(value: any) {
if (value == null) {
return undefined;
}
if (Array.isArray(value)) {
return value.join(',');
}
return value.toString();
}
let selectIds = 0;