Files
ionic-framework/core/src/components/select/select.tsx
Brandy Carney e5c8c10029 fix(components): include mode classes on components for use in shadow (#17838)
- removes mode-less component classes from the internal CSS, use element instead
- adds mode specific classes `md` or `ios` for styling inside of shadow components
- adds e2e test that verifies mode classes exist on all ionic components, plus checks for specific classes that the components need for internal styling

fixes #17608
2019-04-16 17:28:21 -04:00

560 lines
15 KiB
TypeScript

import { Component, ComponentInterface, Element, Event, EventEmitter, Listen, Method, Prop, State, Watch } from '@stencil/core';
import { ActionSheetButton, ActionSheetOptions, AlertInput, AlertOptions, CssClassMap, Mode, OverlaySelect, PopoverOptions, SelectChangeEventDetail, SelectInterface, SelectPopoverOption, StyleEventDetail } from '../../interface';
import { findItemLabel, renderHiddenInput } from '../../utils/helpers';
import { hostContext } from '../../utils/theme';
import { SelectCompareFn } from './select-interface';
@Component({
tag: 'ion-select',
styleUrls: {
ios: 'select.ios.scss',
md: 'select.md.scss'
},
shadow: true
})
export class Select implements ComponentInterface {
private childOpts: HTMLIonSelectOptionElement[] = [];
private inputId = `ion-sel-${selectIds++}`;
private overlay?: OverlaySelect;
private didInit = false;
private buttonEl?: HTMLButtonElement;
@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;
/**
* The mode determines which platform styles to use.
*/
@Prop() mode!: Mode;
/**
* 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<SelectChangeEventDetail>;
/**
* 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.
* @internal
*/
@Event() ionStyle!: EventEmitter<StyleEventDetail>;
@Watch('disabled')
disabledChanged() {
this.emitStyle();
}
@Watch('value')
valueChanged() {
if (this.didInit) {
this.updateOptions();
this.ionChange.emit({
value: this.value,
});
this.emitStyle();
}
}
@Listen('ionSelectOptionDidLoad')
@Listen('ionSelectOptionDidUnload')
async selectOptionChanged() {
await this.loadOptions();
if (this.didInit) {
this.updateOptions();
this.updateOverlayOptions();
this.emitStyle();
/**
* In the event that options
* are not loaded at component load
* this ensures that any value that is
* set is properly rendered once
* options have been loaded
*/
if (this.value !== undefined) {
this.el.forceUpdate();
}
}
}
@Listen('click')
onClick(ev: UIEvent) {
this.setFocus();
this.open(ev);
}
async componentDidLoad() {
await this.loadOptions();
if (this.value === undefined) {
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 = checked.map(o => o.value);
} else {
const checked = this.childOpts.find(o => o.selected);
if (checked) {
this.value = checked.value;
}
}
}
this.updateOptions();
this.emitStyle();
this.el.forceUpdate();
this.didInit = true;
}
/**
* Opens the select overlay, it could be an alert, action-sheet or popover,
* based in `ion-select` settings.
*/
@Method()
async open(ev?: UIEvent): Promise<OverlaySelect | undefined> {
if (this.disabled || this.isExpanded) {
return undefined;
}
const overlay = this.overlay = await this.createOverlay(ev);
this.isExpanded = true;
overlay.onDidDismiss().then(() => {
this.overlay = undefined;
this.isExpanded = false;
this.setFocus();
});
await overlay.present();
return overlay;
}
private createOverlay(ev?: UIEvent): Promise<OverlaySelect> {
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 {
if (!this.overlay) { return; }
const overlay = (this.overlay as any);
switch (this.interface) {
case 'action-sheet':
overlay.buttons = this.createActionSheetButtons(this.childOpts);
break;
case 'popover':
const popover = overlay.querySelector('ion-select-popover');
if (popover) {
popover.options = this.createPopoverOptions(this.childOpts);
}
break;
default:
const inputType = (this.multiple ? 'checkbox' : 'radio');
overlay.inputs = this.createAlertInputs(this.childOpts, inputType);
break;
}
}
private createActionSheetButtons(data: any[]): ActionSheetButton[] {
const actionSheetButtons = data.map(option => {
return {
role: (option.selected ? 'selected' : ''),
text: option.textContent,
handler: () => {
this.value = option.value;
}
} as ActionSheetButton;
});
// Add "cancel" button
actionSheetButtons.push({
text: this.cancelText,
role: 'cancel',
handler: () => {
this.ionCancel.emit();
}
});
return actionSheetButtons;
}
private createAlertInputs(data: any[], inputType: string): AlertInput[] {
return data.map(o => {
return {
type: inputType,
label: o.textContent,
value: o.value,
checked: o.selected,
disabled: o.disabled
} as AlertInput;
});
}
private createPopoverOptions(data: any[]): SelectPopoverOption[] {
return data.map(o => {
return {
text: o.textContent,
value: o.value,
checked: o.selected,
disabled: o.disabled,
handler: () => {
this.value = o.value;
this.close();
}
} as SelectPopoverOption;
});
}
private async openPopover(ev: UIEvent) {
const interfaceOptions = this.interfaceOptions;
const popoverOpts: PopoverOptions = {
mode: this.mode,
...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.createPopoverOptions(this.childOpts)
}
};
return this.popoverCtrl.create(popoverOpts);
}
private async openActionSheet() {
const interfaceOptions = this.interfaceOptions;
const actionSheetOpts: ActionSheetOptions = {
mode: this.mode,
...interfaceOptions,
buttons: this.createActionSheetButtons(this.childOpts),
cssClass: ['select-action-sheet', interfaceOptions.cssClass]
};
return this.actionSheetCtrl.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 alertOpts: AlertOptions = {
mode: this.mode,
...interfaceOptions,
header: interfaceOptions.header ? interfaceOptions.header : labelText,
inputs: this.createAlertInputs(this.childOpts, inputType),
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 this.alertCtrl.create(alertOpts);
}
/**
* Close the select interface.
*/
private close(): Promise<boolean> {
// TODO check !this.overlay || !this.isFocus()
if (!this.overlay) {
return Promise.resolve(false);
}
return this.overlay.dismiss();
}
private async loadOptions() {
this.childOpts = await Promise.all(
Array.from(this.el.querySelectorAll('ion-select-option')).map(o => o.componentOnReady())
);
}
private updateOptions() {
// iterate all options, updating the selected prop
let canSelect = true;
for (const selectOption of this.childOpts) {
const selected = canSelect && isOptionSelected(this.value, selectOption.value, this.compareWith);
selectOption.selected = selected;
// if current option is selected and select is single-option, we can't select
// any option more
if (selected && !this.multiple) {
canSelect = false;
}
}
}
private getLabel() {
return findItemLabel(this.el);
}
private hasValue(): boolean {
return this.getText() !== '';
}
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 onFocus = () => {
this.ionFocus.emit();
}
private onBlur = () => {
this.ionBlur.emit();
}
hostData() {
const labelId = this.inputId + '-lbl';
const label = findItemLabel(this.el);
if (label) {
label.id = labelId;
}
return {
'role': 'combobox',
'aria-disabled': this.disabled ? 'true' : null,
'aria-expanded': `${this.isExpanded}`,
'aria-haspopup': 'dialog',
'aria-labelledby': labelId,
class: {
[`${this.mode}`]: true,
'in-item': hostContext('ion-item', this.el),
'select-disabled': this.disabled,
}
};
}
render() {
renderHiddenInput(true, this.el, this.name, parseValue(this.value), this.disabled);
const labelId = this.inputId + '-lbl';
const label = findItemLabel(this.el);
if (label) {
label.id = labelId;
}
let addPlaceholderClass = false;
let selectText = this.getText();
if (selectText === '' && this.placeholder != null) {
selectText = this.placeholder;
addPlaceholderClass = true;
}
const selectTextClasses: CssClassMap = {
'select-text': true,
'select-placeholder': addPlaceholderClass
};
return [
<div class={selectTextClasses}>
{selectText}
</div>,
<div class="select-icon" role="presentation">
<div class="select-icon-inner"></div>
</div>,
<button
type="button"
onFocus={this.onFocus}
onBlur={this.onBlur}
disabled={this.disabled}
ref={(el => this.buttonEl = el)}
>
</button>
];
}
}
function parseValue(value: any) {
if (value == null) {
return undefined;
}
if (Array.isArray(value)) {
return value.join(',');
}
return value.toString();
}
function 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);
}
}
function 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 currentValue === compareValue;
}
}
function 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) || '';
}
}
function textForValue(opts: HTMLIonSelectOptionElement[], value: any, compareWith?: string | SelectCompareFn | null): string | null {
const selectOpt = opts.find(opt => {
return compareOptions(opt.value, value, compareWith);
});
return selectOpt
? selectOpt.textContent
: null;
}
let selectIds = 0;