mirror of
https://github.com/ionic-team/ionic-framework.git
synced 2025-08-16 18:17:31 +08:00
1209 lines
37 KiB
TypeScript
1209 lines
37 KiB
TypeScript
import caretDownRegular from '@phosphor-icons/core/assets/regular/caret-down.svg';
|
|
import type { ComponentInterface, EventEmitter } from '@stencil/core';
|
|
import { Component, Element, Event, Host, Method, Prop, State, Watch, h, forceUpdate } from '@stencil/core';
|
|
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, modalController } from '@utils/overlays';
|
|
import type { OverlaySelect } from '@utils/overlays-interface';
|
|
import { isRTL } from '@utils/rtl';
|
|
import { createColorClasses, hostContext } from '@utils/theme';
|
|
import { watchForOptions } from '@utils/watch-options';
|
|
import { caretDownSharp, chevronExpand } from 'ionicons/icons';
|
|
|
|
import { config } from '../../global/config';
|
|
import { getIonTheme } from '../../global/ionic-global';
|
|
import type {
|
|
ActionSheetOptions,
|
|
AlertOptions,
|
|
Color,
|
|
CssClassMap,
|
|
PopoverOptions,
|
|
StyleEventDetail,
|
|
ModalOptions,
|
|
} from '../../interface';
|
|
import type { ActionSheetButton } from '../action-sheet/action-sheet-interface';
|
|
import type { AlertInput } from '../alert/alert-interface';
|
|
import type { SelectPopoverOption } from '../select-popover/select-popover-interface';
|
|
|
|
import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from './select-interface';
|
|
|
|
// TODO(FW-2832): types
|
|
|
|
/**
|
|
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
|
|
* @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
|
|
*
|
|
* @slot label - The label text to associate with the select. Use the `labelPlacement` property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML.
|
|
* @slot start - Content to display at the leading edge of the select.
|
|
* @slot end - Content to display at the trailing edge of the select.
|
|
*
|
|
* @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.
|
|
* @part container - The container for the selected text or placeholder.
|
|
* @part label - The label text describing the select.
|
|
*/
|
|
@Component({
|
|
tag: 'ion-select',
|
|
styleUrls: {
|
|
ios: 'select.ios.scss',
|
|
md: 'select.md.scss',
|
|
ionic: 'select.ionic.scss',
|
|
},
|
|
shadow: true,
|
|
})
|
|
export class Select implements ComponentInterface {
|
|
private inputId = `ion-sel-${selectIds++}`;
|
|
private overlay?: OverlaySelect;
|
|
private focusEl?: HTMLButtonElement;
|
|
private mutationO?: MutationObserver;
|
|
private inheritedAttributes: Attributes = {};
|
|
private nativeWrapperEl: HTMLElement | undefined;
|
|
private notchSpacerEl: HTMLElement | undefined;
|
|
|
|
private notchController?: NotchController;
|
|
|
|
@Element() el!: HTMLIonSelectElement;
|
|
|
|
@State() isExpanded = false;
|
|
|
|
/**
|
|
* The text to display on the cancel button.
|
|
*/
|
|
@Prop() cancelText = 'Cancel';
|
|
|
|
/**
|
|
* The color to use from your application's color palette.
|
|
* Default options are: `"primary"`, `"secondary"`, `"tertiary"`, `"success"`, `"warning"`, `"danger"`, `"light"`, `"medium"`, and `"dark"`.
|
|
* For more information on colors, see [theming](/docs/theming/basics).
|
|
*
|
|
* This property is only available when using the modern select syntax.
|
|
*/
|
|
@Prop({ reflect: true }) color?: Color;
|
|
|
|
/**
|
|
* This property allows developers to specify a custom function or property
|
|
* name for comparing objects when determining the selected option in the
|
|
* ion-select. When not specified, the default behavior will use strict
|
|
* equality (===) for comparison.
|
|
*/
|
|
@Prop() compareWith?: string | SelectCompareFn | null;
|
|
|
|
/**
|
|
* If `true`, the user cannot interact with the select.
|
|
*/
|
|
@Prop() disabled = false;
|
|
|
|
/**
|
|
* The fill for the item. If `"solid"` the item will have a background. If
|
|
* `"outline"` the item will be transparent with a border. Only available in the `"md"` theme.
|
|
*/
|
|
@Prop() fill?: 'outline' | 'solid';
|
|
|
|
/**
|
|
* 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), 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.
|
|
*/
|
|
@Prop() interfaceOptions: any = {};
|
|
|
|
/**
|
|
* How to pack the label and select within a line.
|
|
* `justify` does not apply when the label and select
|
|
* are on different lines when `labelPlacement` is set to
|
|
* `"floating"` or `"stacked"`.
|
|
* `"start"`: The label and select will appear on the left in LTR and
|
|
* on the right in RTL.
|
|
* `"end"`: The label and select will appear on the right in LTR and
|
|
* on the left in RTL.
|
|
* `"space-between"`: The label and select will appear on opposite
|
|
* ends of the line with space between the two elements.
|
|
*/
|
|
@Prop() justify?: 'start' | 'end' | 'space-between';
|
|
|
|
/**
|
|
* The visible label associated with the select.
|
|
*
|
|
* Use this if you need to render a plaintext label.
|
|
*
|
|
* The `label` property will take priority over the `label` slot if both are used.
|
|
*/
|
|
@Prop() label?: string;
|
|
|
|
/**
|
|
* Where to place the label relative to the select.
|
|
* `"start"`: The label will appear to the left of the select in LTR and to the right in RTL.
|
|
* `"end"`: The label will appear to the right of the select in LTR and to the left in RTL.
|
|
* `"floating"`: The label will appear smaller and above the select when the select is focused or it has a value. Otherwise it will appear on top of the select.
|
|
* `"stacked"`: The label will appear smaller and above the select regardless even when the select is blurred or has no value.
|
|
* `"fixed"`: The label has the same behavior as `"start"` except it also has a fixed width. Long text will be truncated with ellipses ("...").
|
|
* When using `"floating"` or `"stacked"` we recommend initializing the select with either a `value` or a `placeholder`.
|
|
*/
|
|
@Prop() labelPlacement?: 'start' | 'end' | 'floating' | 'stacked' | 'fixed' = 'start';
|
|
|
|
/**
|
|
* If `true`, the select can accept multiple values.
|
|
*/
|
|
@Prop() multiple = false;
|
|
|
|
/**
|
|
* The name of the control, which is submitted with the form data.
|
|
*/
|
|
@Prop() name: string = this.inputId;
|
|
|
|
/**
|
|
* The text to display on the ok button.
|
|
*/
|
|
@Prop() okText = 'OK';
|
|
|
|
/**
|
|
* The text to display when the select is empty.
|
|
*/
|
|
@Prop() placeholder?: string;
|
|
|
|
/**
|
|
* The text to display instead of the selected option's value.
|
|
*/
|
|
@Prop() selectedText?: string | null;
|
|
|
|
/**
|
|
* The toggle icon to use. Defaults to `"chevronExpand"` for the `"ios"` theme,
|
|
* or `"caretDownSharp"` for the `"md"` and `"ionic"` themes.
|
|
*/
|
|
@Prop() toggleIcon?: string;
|
|
|
|
/**
|
|
* The toggle icon to show when the select is open. If defined, the icon
|
|
* rotation behavior in `"md"` theme will be disabled. If undefined, `toggleIcon`
|
|
* will be used for when the select is both open and closed.
|
|
*/
|
|
@Prop() expandedIcon?: string;
|
|
|
|
/**
|
|
* Set to `"soft"` for a select with slightly rounded corners,
|
|
* `"round"` for a select with fully rounded corners,
|
|
* or `"rectangular"` for a select without rounded corners.
|
|
*
|
|
* Defaults to `"round"` for the `"ionic"` theme, undefined for all other themes.
|
|
*/
|
|
@Prop() shape?: 'soft' | 'round' | 'rectangular';
|
|
|
|
/**
|
|
* The value of the select.
|
|
*/
|
|
@Prop({ mutable: true }) value?: any | null;
|
|
|
|
/**
|
|
* Emitted when the value has changed.
|
|
*
|
|
* This event will not emit when programmatically setting the `value` property.
|
|
*/
|
|
@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('isExpanded')
|
|
@Watch('placeholder')
|
|
@Watch('value')
|
|
protected styleChanged() {
|
|
this.emitStyle();
|
|
}
|
|
|
|
private setValue(value?: any | null) {
|
|
this.value = value;
|
|
this.ionChange.emit({ value });
|
|
}
|
|
|
|
async connectedCallback() {
|
|
const { el } = this;
|
|
|
|
this.notchController = createNotchController(
|
|
el,
|
|
() => this.notchSpacerEl,
|
|
() => this.labelSlot
|
|
);
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
componentWillLoad() {
|
|
this.inheritedAttributes = inheritAttributes(this.el, ['aria-label']);
|
|
}
|
|
|
|
componentDidLoad() {
|
|
/**
|
|
* If any of the conditions that trigger the styleChanged callback
|
|
* are met on component load, it is possible the event emitted
|
|
* prior to a parent web component registering an event listener.
|
|
*
|
|
* To ensure the parent web component receives the event, we
|
|
* emit the style event again after the component has loaded.
|
|
*
|
|
* This is often seen in Angular with the `dist` output target.
|
|
*/
|
|
this.emitStyle();
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
if (this.mutationO) {
|
|
this.mutationO.disconnect();
|
|
this.mutationO = undefined;
|
|
}
|
|
|
|
if (this.notchController) {
|
|
this.notchController.destroy();
|
|
this.notchController = undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 and modals
|
|
if (this.interface === 'popover' || this.interface === 'modal') {
|
|
const indexOfSelected = this.childOpts.findIndex((o) => o.value === this.value);
|
|
|
|
if (indexOfSelected > -1) {
|
|
const selectedItem = overlay.querySelector<HTMLElement>(
|
|
`.select-interface-option:nth-child(${indexOfSelected + 1})`
|
|
);
|
|
|
|
if (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') as
|
|
| HTMLIonRadioElement
|
|
| HTMLIonCheckboxElement
|
|
| null;
|
|
if (interactiveEl) {
|
|
// Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
|
|
// and removing `ion-focused` style
|
|
interactiveEl.setFocus();
|
|
}
|
|
|
|
focusVisibleElement(selectedItem);
|
|
}
|
|
} else {
|
|
/**
|
|
* If no value is set then focus the first enabled option.
|
|
*/
|
|
const firstEnabledOption = overlay.querySelector<HTMLElement>(
|
|
'ion-radio:not(.radio-disabled), ion-checkbox:not(.checkbox-disabled)'
|
|
) as HTMLIonRadioElement | HTMLIonCheckboxElement | null;
|
|
|
|
if (firstEnabledOption) {
|
|
/**
|
|
* Focus the option for the same reason as we do above.
|
|
*
|
|
* Needs to be called before `focusVisibleElement` to prevent issue with focus event bubbling
|
|
* and removing `ion-focused` style
|
|
*/
|
|
firstEnabledOption.setFocus();
|
|
|
|
focusVisibleElement(firstEnabledOption.closest('ion-item')!);
|
|
}
|
|
}
|
|
}
|
|
|
|
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!);
|
|
}
|
|
if (selectInterface === 'modal') {
|
|
return this.openModal();
|
|
}
|
|
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.createOverlaySelectOptions(childOpts, value);
|
|
}
|
|
break;
|
|
case 'modal':
|
|
const modal = overlay.querySelector('ion-select-modal');
|
|
if (modal) {
|
|
modal.options = this.createOverlaySelectOptions(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.setValue(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 createOverlaySelectOptions(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.setValue(selected);
|
|
if (!this.multiple) {
|
|
this.close();
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
return popoverOptions;
|
|
}
|
|
|
|
private async openPopover(ev: UIEvent) {
|
|
const { fill, labelPlacement } = this;
|
|
const interfaceOptions = this.interfaceOptions;
|
|
const theme = getIonTheme(this);
|
|
const showBackdrop = theme === 'md' ? false : true;
|
|
const multiple = this.multiple;
|
|
const value = this.value;
|
|
|
|
let event: Event | CustomEvent = ev;
|
|
let size = 'auto';
|
|
|
|
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
|
/**
|
|
* The popover should take up the full width
|
|
* when using a fill in MD mode or if the
|
|
* label is floating/stacked.
|
|
*/
|
|
if (hasFloatingOrStackedLabel || (theme === 'md' && fill !== undefined)) {
|
|
size = 'cover';
|
|
|
|
/**
|
|
* Otherwise the popover
|
|
* should be positioned relative
|
|
* to the native element.
|
|
*/
|
|
} else {
|
|
event = {
|
|
...ev,
|
|
detail: {
|
|
ionShadowTarget: this.nativeWrapperEl,
|
|
},
|
|
};
|
|
}
|
|
|
|
const popoverOpts: PopoverOptions = {
|
|
theme,
|
|
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.createOverlaySelectOptions(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 theme = getIonTheme(this);
|
|
const interfaceOptions = this.interfaceOptions;
|
|
const actionSheetOpts: ActionSheetOptions = {
|
|
theme,
|
|
...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 interfaceOptions = this.interfaceOptions;
|
|
const inputType = this.multiple ? 'checkbox' : 'radio';
|
|
const theme = getIonTheme(this);
|
|
|
|
const alertOpts: AlertOptions = {
|
|
theme,
|
|
...interfaceOptions,
|
|
|
|
header: interfaceOptions.header ? interfaceOptions.header : this.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.setValue(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);
|
|
}
|
|
|
|
private openModal() {
|
|
const { multiple, value, interfaceOptions } = this;
|
|
const theme = getIonTheme(this);
|
|
|
|
const modalOpts: ModalOptions = {
|
|
...interfaceOptions,
|
|
mode: theme,
|
|
|
|
cssClass: ['select-modal', interfaceOptions.cssClass],
|
|
component: 'ion-select-modal',
|
|
componentProps: {
|
|
header: interfaceOptions.header,
|
|
multiple,
|
|
value,
|
|
options: this.createOverlaySelectOptions(this.childOpts, value),
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 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-modal');
|
|
document.createElement('ion-modal');
|
|
}
|
|
|
|
return modalController.create(modalOpts);
|
|
}
|
|
|
|
/**
|
|
* Close the select interface.
|
|
*/
|
|
private close(): Promise<boolean> {
|
|
if (!this.overlay) {
|
|
return Promise.resolve(false);
|
|
}
|
|
return this.overlay.dismiss();
|
|
}
|
|
|
|
private hasValue(): boolean {
|
|
return this.getText() !== '';
|
|
}
|
|
|
|
private get childOpts() {
|
|
return Array.from(this.el.querySelectorAll('ion-select-option'));
|
|
}
|
|
|
|
/**
|
|
* Returns any plaintext associated with
|
|
* the label (either prop or slot).
|
|
* Note: This will not return any custom
|
|
* HTML. Use the `hasLabel` getter if you
|
|
* want to know if any slotted label content
|
|
* was passed.
|
|
*/
|
|
private get labelText() {
|
|
const { label } = this;
|
|
|
|
if (label !== undefined) {
|
|
return label;
|
|
}
|
|
|
|
const { labelSlot } = this;
|
|
|
|
if (labelSlot !== null) {
|
|
return labelSlot.textContent;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
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() {
|
|
const { disabled } = this;
|
|
|
|
const style: StyleEventDetail = {
|
|
'interactive-disabled': disabled,
|
|
};
|
|
|
|
this.ionStyle.emit(style);
|
|
}
|
|
|
|
private onClick = (ev: UIEvent) => {
|
|
const target = ev.target as HTMLElement;
|
|
const closestSlot = target.closest('[slot="start"], [slot="end"]');
|
|
|
|
if (target === this.el || closestSlot === null) {
|
|
this.setFocus();
|
|
this.open(ev);
|
|
} else {
|
|
/**
|
|
* Prevent clicks to the start/end slots from opening the select.
|
|
* We ensure the target isn't this element in case the select is slotted
|
|
* in, for example, an item. This would prevent the select from ever
|
|
* being opened since the element itself has slot="start"/"end".
|
|
*
|
|
* Clicking a slotted element also causes a click
|
|
* on the <label> element (since it wraps the slots).
|
|
* Clicking <label> dispatches another click event on
|
|
* the native form control that then bubbles up to this
|
|
* listener. This additional event targets the host
|
|
* element, so the select overlay is opened.
|
|
*
|
|
* When the slotted elements are clicked (and therefore
|
|
* the ancestor <label> element) we want to prevent the label
|
|
* from dispatching another click event.
|
|
*
|
|
* Do not call stopPropagation() because this will cause
|
|
* click handlers on the slotted elements to never fire in React.
|
|
* When developers do onClick in React a native "click" listener
|
|
* is added on the root element, not the slotted element. When that
|
|
* native click listener fires, React then dispatches the synthetic
|
|
* click event on the slotted element. However, if stopPropagation
|
|
* is called then the native click event will never bubble up
|
|
* to the root element.
|
|
*/
|
|
ev.preventDefault();
|
|
}
|
|
};
|
|
|
|
private onFocus = () => {
|
|
this.ionFocus.emit();
|
|
};
|
|
|
|
private onBlur = () => {
|
|
this.ionBlur.emit();
|
|
};
|
|
|
|
private renderLabel() {
|
|
const { label } = this;
|
|
|
|
return (
|
|
<div
|
|
class={{
|
|
'label-text-wrapper': true,
|
|
'label-text-wrapper-hidden': !this.hasLabel,
|
|
}}
|
|
part="label"
|
|
>
|
|
{label === undefined ? <slot name="label"></slot> : <div class="label-text">{label}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
componentDidRender() {
|
|
this.notchController?.calculateNotchWidth();
|
|
}
|
|
|
|
/**
|
|
* Gets any content passed into the `label` slot,
|
|
* not the <slot> definition.
|
|
*/
|
|
private get labelSlot() {
|
|
return this.el.querySelector('[slot="label"]');
|
|
}
|
|
|
|
/**
|
|
* Returns `true` if label content is provided
|
|
* either by a prop or a content. If you want
|
|
* to get the plaintext value of the label use
|
|
* the `labelText` getter instead.
|
|
*/
|
|
private get hasLabel() {
|
|
return this.label !== undefined || this.labelSlot !== null;
|
|
}
|
|
|
|
/**
|
|
* Renders the border container
|
|
* when fill="outline".
|
|
*/
|
|
private renderLabelContainer() {
|
|
const theme = getIonTheme(this);
|
|
const hasOutlineFill = theme === 'md' && this.fill === 'outline';
|
|
|
|
if (hasOutlineFill) {
|
|
/**
|
|
* The outline fill has a special outline
|
|
* that appears around the select and the label.
|
|
* Certain stacked and floating label placements cause the
|
|
* label to translate up and create a "cut out"
|
|
* inside of that border by using the notch-spacer element.
|
|
*/
|
|
return [
|
|
<div class="select-outline-container">
|
|
<div class="select-outline-start"></div>
|
|
<div
|
|
class={{
|
|
'select-outline-notch': true,
|
|
'select-outline-notch-hidden': !this.hasLabel,
|
|
}}
|
|
>
|
|
<div class="notch-spacer" aria-hidden="true" ref={(el) => (this.notchSpacerEl = el)}>
|
|
{this.label}
|
|
</div>
|
|
</div>
|
|
<div class="select-outline-end"></div>
|
|
</div>,
|
|
this.renderLabel(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* If not using the outline style,
|
|
* we can render just the label.
|
|
*/
|
|
return this.renderLabel();
|
|
}
|
|
|
|
/**
|
|
* Renders either the placeholder
|
|
* or the selected values based on
|
|
* the state of the select.
|
|
*/
|
|
private renderSelectText() {
|
|
const { placeholder } = this;
|
|
|
|
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';
|
|
|
|
return (
|
|
<div aria-hidden="true" class={selectTextClasses} part={textPart}>
|
|
{selectText}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders the chevron icon
|
|
* next to the select text.
|
|
*/
|
|
private renderSelectIcon() {
|
|
const { isExpanded, selectExpandedIcon, selectCollapsedIcon } = this;
|
|
let icon = selectCollapsedIcon;
|
|
|
|
if (isExpanded) {
|
|
icon = selectExpandedIcon;
|
|
}
|
|
|
|
return <ion-icon class="select-icon" part="icon" aria-hidden="true" icon={icon}></ion-icon>;
|
|
}
|
|
|
|
private get ariaLabel() {
|
|
const { placeholder, inheritedAttributes } = this;
|
|
const displayValue = this.getText();
|
|
|
|
// The aria label should be preferred over visible text if both are specified
|
|
const definedLabel = inheritedAttributes['aria-label'] ?? this.labelText;
|
|
|
|
/**
|
|
* If developer has specified a placeholder
|
|
* and there is nothing selected, the selectText
|
|
* should have the placeholder value.
|
|
*/
|
|
let renderedLabel = displayValue;
|
|
if (renderedLabel === '' && placeholder !== undefined) {
|
|
renderedLabel = placeholder;
|
|
}
|
|
|
|
/**
|
|
* If there is a developer-defined label,
|
|
* then we need to concatenate the developer label
|
|
* string with the current current value.
|
|
* The label for the control should be read
|
|
* before the values of the control.
|
|
*/
|
|
if (definedLabel !== undefined) {
|
|
renderedLabel = renderedLabel === '' ? definedLabel : `${definedLabel}, ${renderedLabel}`;
|
|
}
|
|
|
|
return renderedLabel;
|
|
}
|
|
|
|
private renderListbox() {
|
|
const { disabled, inputId, isExpanded } = this;
|
|
|
|
return (
|
|
<button
|
|
disabled={disabled}
|
|
id={inputId}
|
|
aria-label={this.ariaLabel}
|
|
aria-haspopup="dialog"
|
|
aria-expanded={`${isExpanded}`}
|
|
onFocus={this.onFocus}
|
|
onBlur={this.onBlur}
|
|
ref={(focusEl) => (this.focusEl = focusEl)}
|
|
></button>
|
|
);
|
|
}
|
|
|
|
private getShape(): string | undefined {
|
|
const theme = getIonTheme(this);
|
|
const { shape } = this;
|
|
|
|
// TODO(ROU-11366): Remove theme check when shapes are defined for all themes.
|
|
if (theme === 'ionic' && shape === undefined) {
|
|
return 'round';
|
|
}
|
|
|
|
return shape;
|
|
}
|
|
|
|
/**
|
|
* Get the icon to use for the expand icon.
|
|
* If an icon is set on the component, use that.
|
|
* Otherwise, use the icon set in the config.
|
|
* If no icon is set in the config, use the default icon.
|
|
*/
|
|
get selectExpandedIcon(): string {
|
|
// Return the expandedIcon or toggleIcon if either is explicitly set
|
|
if (this.expandedIcon != null) {
|
|
return this.expandedIcon;
|
|
} else if (this.toggleIcon != null) {
|
|
return this.toggleIcon;
|
|
}
|
|
|
|
// Determine the theme and map to default icons
|
|
const theme = getIonTheme(this);
|
|
const defaultIcons = {
|
|
ios: chevronExpand,
|
|
ionic: caretDownRegular,
|
|
md: caretDownSharp,
|
|
};
|
|
|
|
// Get the default icon based on the theme, falling back to 'md' icon if necessary
|
|
const defaultIcon = defaultIcons[theme] || defaultIcons.md;
|
|
|
|
// Return the configured select expanded icon or the default icon
|
|
return config.get('selectExpandedIcon', defaultIcon);
|
|
}
|
|
|
|
/**
|
|
* Get the icon to use for the collapsed icon.
|
|
* If an icon is set on the component, use that.
|
|
* Otherwise, use the icon set in the config.
|
|
* If no icon is set in the config, use the default icon.
|
|
*/
|
|
get selectCollapsedIcon(): string {
|
|
// Return the toggleIcon if it is explicitly set
|
|
if (this.toggleIcon) {
|
|
return this.toggleIcon;
|
|
}
|
|
|
|
// Determine the theme and map to default icons
|
|
const theme = getIonTheme(this);
|
|
const defaultIcons = {
|
|
ios: chevronExpand,
|
|
ionic: caretDownRegular,
|
|
md: caretDownSharp,
|
|
};
|
|
|
|
// Get the default icon based on the theme, falling back to 'md' icon if necessary
|
|
const defaultIcon = defaultIcons[theme] || defaultIcons.md;
|
|
|
|
return config.get('selectCollapsedIcon', defaultIcon);
|
|
}
|
|
|
|
render() {
|
|
const { disabled, el, isExpanded, expandedIcon, labelPlacement, justify, placeholder, fill, name, value } = this;
|
|
const theme = getIonTheme(this);
|
|
const shape = this.getShape();
|
|
const hasFloatingOrStackedLabel = labelPlacement === 'floating' || labelPlacement === 'stacked';
|
|
const shouldRenderOuterIcon = theme !== 'ionic' && hasFloatingOrStackedLabel;
|
|
const shouldRenderInnerIcon = theme === 'ionic' || !hasFloatingOrStackedLabel;
|
|
const justifyEnabled = !hasFloatingOrStackedLabel && justify !== undefined;
|
|
const rtl = isRTL(el) ? 'rtl' : 'ltr';
|
|
const inItem = hostContext('ion-item', this.el);
|
|
const shouldRenderHighlight = theme === 'md' && fill !== 'outline' && !inItem;
|
|
|
|
const hasValue = this.hasValue();
|
|
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
|
|
|
|
renderHiddenInput(true, el, name, parseValue(value), disabled);
|
|
|
|
/**
|
|
* If the label is stacked, it should always sit above the select.
|
|
* For floating labels, the label should move above the select if
|
|
* the select has a value, is open, or has anything in either
|
|
* the start or end slot.
|
|
*
|
|
* If there is content in the start slot, the label would overlap
|
|
* it if not forced to float. This is also applied to the end slot
|
|
* because with the default or solid fills, the select is not
|
|
* vertically centered in the container, but the label is. This
|
|
* causes the slots and label to appear vertically offset from each
|
|
* other when the label isn't floating above the input. This doesn't
|
|
* apply to the outline fill, but this was not accounted for to keep
|
|
* things consistent.
|
|
*
|
|
* TODO(FW-5592): Remove hasStartEndSlots condition
|
|
*/
|
|
const labelShouldFloat =
|
|
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots));
|
|
|
|
return (
|
|
<Host
|
|
onClick={this.onClick}
|
|
class={createColorClasses(this.color, {
|
|
[theme]: true,
|
|
'in-item': inItem,
|
|
'in-item-color': hostContext('ion-item.ion-color', el),
|
|
'select-disabled': disabled,
|
|
'select-expanded': isExpanded,
|
|
'has-expanded-icon': expandedIcon !== undefined,
|
|
'has-value': hasValue,
|
|
'label-floating': labelShouldFloat,
|
|
'has-placeholder': placeholder !== undefined,
|
|
'ion-focusable': true,
|
|
[`select-${rtl}`]: true,
|
|
[`select-fill-${fill}`]: fill !== undefined,
|
|
[`select-justify-${justify}`]: justifyEnabled,
|
|
[`select-shape-${shape}`]: shape !== undefined,
|
|
[`select-label-placement-${labelPlacement}`]: true,
|
|
})}
|
|
>
|
|
<label class="select-wrapper" id="select-label">
|
|
{this.renderLabelContainer()}
|
|
<div class="select-wrapper-inner">
|
|
{
|
|
/**
|
|
* For the ionic theme, we render the outline container here
|
|
* instead of higher up, so it can be positioned relative to
|
|
* the native wrapper instead of the <label> element or the
|
|
* entire component. This allows the label text to be positioned
|
|
* above the outline, while staying within the bounds of the
|
|
* <label> element, ensuring that clicking the label text
|
|
* focuses the select.
|
|
*/
|
|
theme === 'ionic' && fill === 'outline' && <div class="select-outline"></div>
|
|
}
|
|
<slot name="start"></slot>
|
|
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
|
|
{this.renderSelectText()}
|
|
{this.renderListbox()}
|
|
</div>
|
|
<slot name="end"></slot>
|
|
{shouldRenderInnerIcon && this.renderSelectIcon()}
|
|
</div>
|
|
{/**
|
|
* The icon in a floating/stacked select
|
|
* must be centered with the entire select,
|
|
* while the start/end slots and native control
|
|
* are vertically offset in the default or
|
|
* solid fills. As a result, we render the
|
|
* icon outside the inner wrapper, which holds
|
|
* those components.
|
|
*/}
|
|
{shouldRenderOuterIcon && this.renderSelectIcon()}
|
|
{shouldRenderHighlight && <div class="select-highlight"></div>}
|
|
</label>
|
|
</Host>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 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';
|