UNPKG

@progress/kendo-angular-dropdowns

Version:

A wide variety of native Angular dropdown components including AutoComplete, ComboBox, DropDownList, DropDownTree, MultiColumnComboBox, MultiSelect, and MultiSelectTree

1,411 lines (1,410 loc) 85.4 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { Component, forwardRef, ElementRef, TemplateRef, Input, Output, EventEmitter, ContentChild, ViewChild, ViewContainerRef, HostBinding, isDevMode, ChangeDetectorRef, NgZone, Renderer2, Injector } from '@angular/core'; import { NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; import { validatePackage } from '@progress/kendo-licensing'; import { packageMetadata } from '../package-metadata'; import { SearchBarComponent } from '../common/searchbar.component'; import { ItemTemplateDirective } from '../common/templates/item-template.directive'; import { HeaderTemplateDirective } from '../common/templates/header-template.directive'; import { FooterTemplateDirective } from '../common/templates/footer-template.directive'; import { GroupTemplateDirective } from '../common/templates/group-template.directive'; import { FixedGroupTemplateDirective } from '../common/templates/fixed-group-template.directive'; import { NoDataTemplateDirective } from '../common/templates/no-data-template.directive'; import { SelectionService } from '../common/selection/selection.service'; import { NavigationService } from '../common/navigation/navigation.service'; import { DisabledItemsService } from '../common/disabled-items/disabled-items.service'; import { merge, of, Subject, Subscription } from 'rxjs'; import { catchError, filter, map, partition, tap, throttleTime } from 'rxjs/operators'; import { isChanged, isDocumentAvailable, KendoInput, hasObservers, anyChanged, SuffixTemplateDirective, PrefixTemplateDirective, isControlRequired, MultiTabStop, SeparatorComponent, EventsOutsideAngularDirective, ResizeSensorComponent, Keys, TemplateContextDirective } from '@progress/kendo-angular-common'; import { AdaptiveService } from '@progress/kendo-angular-utils'; import { isPresent, guid, getter, isEmptyString, isUntouched, inDropDown, getSizeClass, getRoundedClass, getFillModeClass, isTruthy, setListBoxAriaLabelledBy, setActionSheetTitle, updateActionSheetAdaptiveAppearance, animationDuration } from '../common/util'; import { NavigationAction } from '../common/navigation/navigation-action'; import { PreventableEvent } from '../common/models/preventable-event'; import { LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n'; import { PopupService } from '@progress/kendo-angular-popup'; import { ComboBoxMessages } from '../common/constants/error-messages'; import { FilterableComponent } from '../common/filtering/filterable-component'; import { DataService } from '../common/data.service'; import { ListComponent } from '../common/list.component'; import { normalizeVirtualizationSettings } from '../common/models/virtualization-settings'; import { pointers, touchEnabled } from '@progress/kendo-common'; import { caretAltDownIcon, xIcon } from '@progress/kendo-svg-icons'; import { ResponsiveRendererComponent } from '../common/action-sheet.component'; import { NgIf, NgTemplateOutlet, NgClass } from '@angular/common'; import { SharedDropDownEventsDirective } from '../common/shared-events.directive'; import { LocalizedMessagesDirective } from '../common/localization/localized-messages.directive'; import { IconWrapperComponent } from '@progress/kendo-angular-icons'; import * as i0 from "@angular/core"; import * as i1 from "@progress/kendo-angular-l10n"; import * as i2 from "@progress/kendo-angular-popup"; import * as i3 from "../common/selection/selection.service"; import * as i4 from "../common/navigation/navigation.service"; import * as i5 from "../common/disabled-items/disabled-items.service"; import * as i6 from "../common/data.service"; import * as i7 from "@progress/kendo-angular-utils"; /** * @hidden */ export const COMBOBOX_VALUE_ACCESSOR = { multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ComboBoxComponent) }; const DEFAULT_SIZE = 'medium'; const DEFAULT_ROUNDED = 'medium'; const DEFAULT_FILL_MODE = 'solid'; /** * Represents the [Kendo UI ComboBox component for Angular]({% slug overview_combobox %}). * * @example * ```ts * _@Component({ * selector: 'my-app', * template: ` * <kendo-combobox [data]="listItems"> * </kendo-combobox> * ` * }) * class AppComponent { * public listItems: Array<string> = ["Item 1", "Item 2", "Item 3", "Item 4"]; * } * ``` */ export class ComboBoxComponent extends MultiTabStop { wrapper; localization; popupService; selectionService; navigationService; disabledItemsService; dataService; zone; cdr; renderer; injector; hostElement; adaptiveService; /** * @hidden */ icon; /** * @hidden */ svgIcon; /** * Sets the HTML attributes of the inner focusable input element. Attributes which are essential for certain component functionalities cannot be changed. */ inputAttributes; /** * @hidden */ animationDuration = animationDuration; /** * @hidden */ xIcon = xIcon; /** * @hidden */ responsiveRendererComponent; /** * @hidden */ get actionSheet() { return this.responsiveRendererComponent?.actionSheet; } /** * @hidden */ get actionSheetSearchBar() { return this.responsiveRendererComponent?.actionSheetSearchBar; } /** * @hidden */ caretAltDownIcon = caretAltDownIcon; set text(text) { this._text = isPresent(text) ? text.toString() : ""; } get text() { return this._text; } /** * @hidden */ get ariaControls() { return this.isOpen ? this.listBoxId : undefined; } /** * @hidden */ get isControlRequired() { return isControlRequired(this.formControl); } /** * @hidden */ togglePopup(open) { const isDisabled = this.disabled || this.readonly; const sameState = this.isOpen === open; if (isDisabled || sameState) { return; } const isDefaultPrevented = this.triggerPopupEvents(open); if (!isDefaultPrevented) { this._toggle(open); } } get activeDescendant() { if (!this.isOpen || !isPresent(this.selectionService.focused) || this.selectionService.focused === -1) { return null; } return this.optionPrefix + "-" + this.selectionService.focused; } get appendTo() { const { appendTo } = this.popupSettings; if (!appendTo || appendTo === 'root') { return undefined; } return appendTo === 'component' ? this.container : appendTo; } dataItem; selected = []; /** * Shows or hides the current group sticky header when using grouped data. * By default the sticky header is displayed ([see example]({% slug grouping_autocomplete %}#toc-sticky-header)). */ showStickyHeader = true; /** * @hidden */ focusableId = `k-${guid()}`; /** * Specifies whether the ComboBox allows user-defined values that are not present in the dataset * ([more information and examples]({% slug custom_values_combobox %})). * Defaults to `false`. * * The feature is not available when using adaptive mode. */ allowCustom = false; /** * Sets the data of the ComboBox. * * > The data has to be provided in an array-like list. */ set data(data) { this.dataService.data = data || []; if (this.virtual) { this.virtual.skip = 0; } this.setState(); if (this._filtering) { const queryAndDataPresent = this.text.length > 0 && this.dataService.itemsCount > 0; const index = queryAndDataPresent ? this.firstFocusableIndex(0) : -1; this.selectionService.focused = index; } if (this.suggest && this.dataService.itemsCount && this.text) { this.suggestedText = getter(this.dataService.itemAt(0), this.textField); } } get data() { const virtual = this.virtual; if (virtual) { const start = virtual.skip || 0; const end = start + virtual.pageSize; // Use length instead of itemsCount because of the grouping. virtual.total = this.dataService.data.length; return this.dataService.data.slice(start, end); } return this.dataService.data; } /** * Sets the value of the ComboBox. * It can either be of the primitive (string, numbers) or of the complex (objects) type. * To define the type, use the `valuePrimitive` option. * * > All selected values which are not present in the dataset are considered custom values. * > When the `Enter` key is pressed or the component loses focus, custom values get dismissed unless `allowCustom` is set to `true`. */ set value(newValue) { this._value = newValue; this.setState(); this.cdr.markForCheck(); } get value() { return this._value; } /** * Sets the data item field that represents the item text. * If the data contains only primitive values, do not define it. * * > The `textField` property can be set to point to a nested property value - e.g. `category.name`. */ textField; /** * Sets the data item field that represents the item value. * If the data contains only primitive values, do not define it. * * > The `valueField` property can be set to point to a nested property value - e.g. `category.id`. */ valueField; /** * Specifies the type of the selected value. * If set to `true`, the selected value has to be of the primitive type * ([more information and example]({% slug valuebinding_combobox %}#toc-primitive-values-from-object-fields)). */ set valuePrimitive(isPrimitive) { this._valuePrimitive = isPrimitive; } get valuePrimitive() { if (!isPresent(this._valuePrimitive)) { return !isPresent(this.valueField); } return this._valuePrimitive; } /** * A user-defined callback which returns normalized custom values. * Typically used when the data items are different from type `string`. * @param { Any } value - The custom value defined by the user. * @returns { Any } * * @example * ```ts * import { map } from 'rxjs/operators'; * * _@Component({ * selector: 'my-app', * template: ` * <kendo-combobox * [allowCustom]="true" * [data]="listItems" * textField="text" * valueField="value" * [valueNormalizer]="valueNormalizer" * (valueChange)="onValueChange($event)" * > * </kendo-combobox> * ` * }) * * class AppComponent { * public listItems: Array<{ text: string, value: number }> = [ * { text: "Small", value: 1 }, * { text: "Medium", value: 2 }, * { text: "Large", value: 3 } * ]; * * public onValueChange(value) { * console.log("valueChange : ", value); * } * * public valueNormalizer = (text$: Observable<string>) => text$.pipe(map((text: string) => { * return { ProductID: null, ProductName: text }; * })); * * } * ``` */ valueNormalizer = (text) => text.pipe(map((userInput) => userInput)); /** * The hint that is displayed when the component is empty. * */ placeholder = ""; /** * Enables or disables the adaptive mode. By default the adaptive rendering is disabled. */ adaptiveMode = 'none'; /** * Sets the title of the ActionSheet that is rendered instead of the Popup when using small screen devices. * By default the ActionSheet title uses the text provided for the label of the AutoComplete. */ title = ''; /** * Sets the subtitle of the ActionSheet that is rendered instead of the Popup when using small screen devices. * By default the ActionSheet subtitle uses the text provided for the `placeholder` of the AutoComplete. */ set subtitle(_subtitle) { this._subtitle = _subtitle; } get subtitle() { return this._subtitle || this.placeholder; } /** * @hidden */ get isAdaptiveModeEnabled() { return this.adaptiveMode === 'auto'; } /** * Configures the popup of the ComboBox. * * The available options are: * - `animate: Boolean`&mdash;Controls the popup animation. By default, the open and close animations are enabled. * - `width: Number | String`&mdash;Sets the width of the popup container. By default, the width of the host element is used. If set to `auto`, the component automatically adjusts the width of the popup and no item labels are wrapped. The `auto` mode is not supported when virtual scrolling is enabled. * - `height: Number`&mdash;Sets the height of the popup container. * - `popupClass: String`&mdash;Specifies a list of CSS classes that are used to style the popup. * - `appendTo: "root" | "component" | ViewContainerRef`&mdash;Specifies the component to which the popup will be appended. */ set popupSettings(settings) { this._popupSettings = Object.assign({ animate: true }, settings); } get popupSettings() { return this._popupSettings; } /** * Sets the height of the options list in the popup. By default, `listHeight` is 200px. * * > The `listHeight` property affects only the list of options and not the whole popup container. * > To set the height of the popup container, use `popupSettings.height`. * * > When using `adaptiveMode` and the screen size is `small` or `medium` the `listHeight` property is set to null. */ set listHeight(_listHeight) { this._listHeight = _listHeight; } get listHeight() { if (this.isAdaptive) { return; } return this._listHeight; } _listHeight = 200; /** * Sets and gets the loading state of the ComboBox. */ loading; /** * Enables the auto-completion of the text based on the first data item. */ suggest = false; /** * If set to `true`, renders a button on hovering over the component. * Clicking this button resets the value of the component to `undefined` and triggers the `change` event. */ clearButton = true; /** * Sets the disabled state of the component. To learn how to disable the component in reactive forms, refer to the articles on [ComboBox Forms Support](slug:formssupport_combobox#toc-managing-the-combobox-disabled-state-in-reactive-forms) and [MultiColumnComboBox Forms Support](slug:formssupport_multicolumncombobox#toc-managing-the-multicolumncombobox-disabled-state-in-reactive-forms). */ disabled = false; /** * Defines a Boolean function that is executed for each data item in the component * ([see examples]({% slug disableditems_combobox %})). Determines whether the item will be disabled. */ set itemDisabled(fn) { if (typeof fn !== 'function') { throw new Error(`itemDisabled must be a function, but received ${JSON.stringify(fn)}.`); } this.disabledItemsService.itemDisabled = fn; } /** * Sets the read-only state of the component. * * @default false */ readonly = false; /** * Specifies the [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the component. */ tabindex = 0; /** * @hidden */ set tabIndex(tabIndex) { this.tabindex = tabIndex; } get tabIndex() { return this.tabindex; } /** * Enables the [filtering]({% slug filtering_combobox %}) functionality. * If set to `true`, the component emits the `filterChange` event. */ filterable = false; /** * Enables the [virtualization]({% slug virtualization_combobox %}) functionality. */ set virtual(settings) { this._virtualSettings = normalizeVirtualizationSettings(settings, { itemHeight: this.defaultVirtualItemHeight, pageSize: this.defaultVirtualPageSize }); } get virtual() { return this._virtualSettings; } /** * Sets the size of the component. * * The possible values are: * * `small` * * `medium` (default) * * `large` * * `none` * */ set size(size) { const newSize = size ? size : DEFAULT_SIZE; this.renderer.removeClass(this.wrapper.nativeElement, getSizeClass('input', this.size)); if (size !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getSizeClass('input', newSize)); } this._size = newSize; } get size() { return this._size; } /** * Sets the border radius of the component. * * The possible values are: * * `small` * * `medium` (default) * * `large` * * `full` * * `none` * */ set rounded(rounded) { const newRounded = rounded ? rounded : DEFAULT_ROUNDED; this.renderer.removeClass(this.wrapper.nativeElement, getRoundedClass(this.rounded)); if (rounded !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getRoundedClass(newRounded)); } this._rounded = newRounded; } get rounded() { return this._rounded; } /** * Sets the fillMode of the component. * * The possible values are: * * `flat` * * `solid` (default) * * `outline` * * `none` * */ set fillMode(fillMode) { const newFillMode = fillMode ? fillMode : DEFAULT_FILL_MODE; this.renderer.removeClass(this.wrapper.nativeElement, getFillModeClass('input', this.fillMode)); if (fillMode !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getFillModeClass('input', newFillMode)); } this._fillMode = newFillMode; } get fillMode() { return this._fillMode; } /** * Fires each time the value is changed&mdash; * when the component is blurred or the value is cleared through the **Clear** button * ([see example](slug:events_combobox)). * When the value of the component is programmatically changed to `ngModel` or `formControl` * through its API or form binding, the `valueChange` event is not triggered because it * might cause a mix-up with the built-in `valueChange` mechanisms of the `ngModel` or `formControl` bindings. */ valueChange = new EventEmitter(); /** * Fires each time an item selection is changed * ([see example](slug:events_combobox)). */ selectionChange = new EventEmitter(); /** * Fires each time the user types in the input field. * You can filter the source based on the passed filtration value * ([see example](slug:events_combobox)). */ filterChange = new EventEmitter(); /** * Fires each time the popup is about to open. * This event is preventable. If you cancel it, the popup will remain closed. */ open = new EventEmitter(); /** * Fires after the popup has been opened. */ opened = new EventEmitter(); /** * Fires each time the popup is about to close. * This event is preventable. If you cancel it, the popup will remain open. */ close = new EventEmitter(); /** * Fires after the popup has been closed. */ closed = new EventEmitter(); /** * Fires each time the user focuses the ComboBox. */ onFocus = new EventEmitter(); /** * Fires each time the ComboBox gets blurred. */ onBlur = new EventEmitter(); /** * Fires each time the user focuses the `input` element. */ inputFocus = new EventEmitter(); /** * Fires each time the `input` element gets blurred. */ inputBlur = new EventEmitter(); /** * @hidden */ escape = new EventEmitter(); template; headerTemplate; footerTemplate; noDataTemplate; groupTemplate; fixedGroupTemplate; /** * @hidden */ suffixTemplate; /** * @hidden */ prefixTemplate; container; popupTemplate; searchbar; optionsList; select; widgetClasses = true; get isDisabled() { return this.disabled; } get isLoading() { return this.loading; } get dir() { return this.direction; } _isFocused = false; get isFocused() { return this._isFocused; } set isFocused(value) { this.renderer[value ? 'addClass' : 'removeClass'](this.wrapper.nativeElement, "k-focus"); this._isFocused = value; } get clearButtonVisiblity() { if (touchEnabled) { return 'visible'; } } /** * @hidden */ get formControl() { const ngControl = this.injector.get(NgControl, null); return ngControl?.control || null; } /** * @hidden */ windowSize = 'large'; /** * @hidden */ get isActionSheetExpanded() { return this.actionSheet?.expanded; } /** * @hidden */ get isAdaptive() { return this.isAdaptiveModeEnabled && this.windowSize !== 'large'; } listBoxId = `k-${guid()}`; optionPrefix = `k-${guid()}`; popupRef; get popupWidth() { let wrapperOffsetWidth = 0; if (isDocumentAvailable()) { wrapperOffsetWidth = this.wrapper.nativeElement.offsetWidth; } const width = this.popupSettings.width || wrapperOffsetWidth; const minWidth = isNaN(wrapperOffsetWidth) ? wrapperOffsetWidth : `${wrapperOffsetWidth}px`; const maxWidth = isNaN(width) ? width : `${width}px`; return { min: minWidth, max: maxWidth }; } get popupHeight() { const popupHeight = this.popupSettings.height; return isPresent(popupHeight) ? `${popupHeight}px` : 'auto'; } onChangeCallback = (_) => { }; onTouchedCallback = (_) => { }; /** * Used for the default virtualization settings config. */ defaultVirtualItemHeight = 28; /** * Used for the default virtualization settings config. */ defaultVirtualPageSize = 50; valueSubscription; _filtering = false; _text = ''; filterText = ''; _open = false; _value; _valuePrimitive; _previousDataItem; suggestedText; backspacePressed; _popupSettings = { animate: true }; _virtualSettings; _subtitle; popupMouseDownHandler = (event) => event.preventDefault(); customValueSubject = new Subject(); valueSubject = new Subject(); clearValueSubject = new Subject(); direction; subs = new Subscription(); touchstartDisposeHandler; selectClickDisposeHandler; _size = 'medium'; _rounded = 'medium'; _fillMode = 'solid'; constructor(wrapper, localization, popupService, selectionService, navigationService, disabledItemsService, dataService, zone, cdr, renderer, injector, hostElement, adaptiveService) { super(); this.wrapper = wrapper; this.localization = localization; this.popupService = popupService; this.selectionService = selectionService; this.navigationService = navigationService; this.disabledItemsService = disabledItemsService; this.dataService = dataService; this.zone = zone; this.cdr = cdr; this.renderer = renderer; this.injector = injector; this.hostElement = hostElement; this.adaptiveService = adaptiveService; validatePackage(packageMetadata); this.direction = localization.rtl ? 'rtl' : 'ltr'; this.data = []; } ngOnInit() { this.renderer.removeAttribute(this.wrapper.nativeElement, 'tabindex'); this.attachStreams(); this.createValueStream(); this.subscribeTouchEvents(); this.attachSelectClickHandler(); this.setComponentClasses(); } ngAfterViewInit() { this.windowSize = this.adaptiveService.size; this.cdr.detectChanges(); if (this.actionSheet && isDocumentAvailable()) { // The following syntax is used as it does not violate CSP compliance this.actionSheet.element.nativeElement.style.setProperty('--kendo-actionsheet-height', '60vh'); this.actionSheet.element.nativeElement.style.setProperty('--kendo-actionsheet-max-height', 'none'); } } createValueStream() { const valueStream = this.valueSubject.pipe(filter((candidate) => { const current = getter(this.value, this.valueField); const newValue = getter(candidate, this.valueField); let newText = getter(candidate, this.textField); if (!isPresent(this.value) && !isPresent(newValue)) { return false; } if (isPresent(newText)) { newText = newText.toString(); } if (current === newValue && this.text === newText) { this.clearFilter(); return false; } else { return true; } }), map((candidate) => { const newValue = getter(candidate, this.valueField); const newText = getter(candidate, this.textField); return { dataItem: candidate, text: newText, value: this.valuePrimitive ? newValue : candidate }; })); const customValueStreams = partition(() => this.allowCustom)(this.customValueSubject.pipe(throttleTime(300))); const allowCustomValueStream = customValueStreams[0].pipe(tap(() => { this.loading = true; this.disabled = true; this.cdr.detectChanges(); }), filter(() => { const hasChange = this.text !== getter(this.value, this.valueField); this.loading = hasChange; this.disabled = hasChange; if (!hasChange) { this.clearFilter(); } return hasChange; }), this.valueNormalizer, map((normalizedValue) => { return { custom: true, dataItem: normalizedValue, text: this.text, value: normalizedValue }; })); const disableCustomValueStream = customValueStreams[1].pipe(map(() => { return { custom: true, dataItem: undefined, text: undefined, value: undefined }; })); const clearValueStream = this.clearValueSubject.pipe(map(() => ({ dataItem: undefined, text: undefined, value: undefined }))); if (this.valueSubscription) { this.valueSubscription.unsubscribe(); } const merged = merge(valueStream, allowCustomValueStream, disableCustomValueStream, clearValueStream); this.valueSubscription = merged.pipe(catchError(() => { const selectionChanged = getter(this.dataItem, this.valueField) !== undefined; this.dataItem = undefined; this.value = undefined; this.text = undefined; this.loading = false; this.disabled = false; if (selectionChanged) { this.selectionChange.emit(undefined); } this.emitValueChange(); this.createValueStream(); return of(null); })) .subscribe((state) => { const selectionChanged = getter(this.dataItem, this.valueField) !== getter(state.dataItem, this.valueField); this.dataItem = state.dataItem; this.value = state.value; this.text = state.text; this.loading = false; this.disabled = false; this.clearFilter(); if (state.custom) { this.selectionService.focused = -1; } if (selectionChanged) { const selectionArgs = state.custom ? undefined : this.dataItem; this.selectionChange.emit(selectionArgs); } this.emitValueChange(); }); } attachStreams() { if (!isDocumentAvailable()) { return; } this.subs.add(this.localization .changes.subscribe(({ rtl }) => { this.direction = rtl ? 'rtl' : 'ltr'; this.cdr.detectChanges(); })); this.subs.add(merge(this.navigationService.up, this.navigationService.down, this.navigationService.home, this.navigationService.end) .pipe(filter((event) => isPresent(event.index))) .subscribe((event) => this.navigate(event.index))); this.subs.add(this.navigationService.open.subscribe(this.handleNavigationOpen.bind(this))); this.subs.add(this.navigationService.close.subscribe(() => this.togglePopup(false))); this.subs.add(merge(this.navigationService.pagedown, this.navigationService.pageup).subscribe((event) => { if (this.isOpen) { event.originalEvent.preventDefault(); this.optionsList.scrollWithOnePage(NavigationAction[event.originalEvent.code]); } })); this.subs.add(this.navigationService.esc.subscribe(this.handleEscape.bind(this))); this.subs.add(this.navigationService.enter.pipe(tap((event) => { if (this.isOpen) { event.originalEvent.preventDefault(); } })) .subscribe(this.handleEnter.bind(this))); this.subs.add(merge(this.selectionService.onChange, this.selectionService.onSelect.pipe(filter(() => !this.isOpen))) .pipe(tap(() => { this._filtering = false; this.togglePopup(false); }), map((event) => this.dataService.itemAt(event.indices[0]))) .subscribe(dataItem => { this.change(dataItem); })); this.subs.add(this.selectionService.onSelect.pipe(filter(() => this.isOpen), tap(() => this._filtering = false), map((event) => this.dataService.itemAt(event.indices[0]))) .subscribe(dataItem => { const selectionChanged = getter(dataItem, this.valueField) !== getter(this.dataItem, this.valueField); this.updateState({ dataItem }); if (selectionChanged) { this.selectionChange.emit(dataItem); } })); } ngOnDestroy() { this.destroyPopup(); this.subs.unsubscribe(); if (isPresent(this.valueSubscription)) { this.valueSubscription.unsubscribe(); } if (this.touchstartDisposeHandler) { this.touchstartDisposeHandler(); } if (this.selectClickDisposeHandler) { this.selectClickDisposeHandler(); } } ngOnChanges(changes) { const virtual = this.virtual; const requestInitialData = virtual && changes['data'] && changes['data'].isFirstChange(); if (requestInitialData) { this.pageChange({ skip: 0, take: virtual.pageSize }); } if (isChanged('valueNormalizer', changes)) { this.createValueStream(); } if (anyChanged(['textField', 'valueField', 'valuePrimitive'], changes, false)) { this.setState(); } } ngAfterContentChecked() { this.verifySettings(); } /** * Focuses a specific item of the ComboBox based on a provided index. * If null or invalid index is provided the focus will be removed. */ focusItemAt(index) { const isInRange = index >= 0 && index < this.data.length; if (isPresent(index) && isInRange && !this.disabledItemsService.isIndexDisabled(index)) { this.selectionService.focus(index); } else { this.selectionService.focus(-1); } } /** * Focuses the ComboBox. */ focus() { if (!this.disabled) { this.searchbar.focus(); } } /** * Blurs the ComboBox. */ blur() { if (!this.disabled) { this.searchbar.blur(); } } /** * Toggles the visibility of the popup or actionSheet. If you use the `toggle` method to open or close the popup or actionSheet, * the `open` and `close` events will not be fired. * * @param open - The state of the popup. */ toggle(open) { Promise.resolve(null).then(() => { const shouldOpen = isPresent(open) ? open : !this._open; this._toggle(shouldOpen); this.cdr.markForCheck(); }); } /** * Returns the current open state. Returns `true` if the popup or actionSheet is open. */ get isOpen() { return isTruthy(this._open || this.isActionSheetExpanded); } /** * @hidden */ handleClick() { this.windowSize = this.adaptiveService.size; if (this.isAdaptive) { this.togglePopup(true); } } /** * Resets the value of the ComboBox. * If you use the `reset` method to clear the value of the component, * the model will not update automatically and the `selectionChange` and `valueChange` events will not be fired. */ reset() { this.value = undefined; this.clearState(); this.resetSelection(); } /** * @hidden * * Used by the TextBoxContainer to determine if the floating label * should be rendered in the input when the component is not focused. */ isEmpty() { const textEmpty = !isPresent(this.text) || isEmptyString(this.text); const valueEmpty = !isPresent(this.value) || isEmptyString(this.value); return textEmpty && valueEmpty; } /** * @hidden */ messageFor(key) { return this.localization.get(key); } /** * @hidden */ clearValue(event) { event?.stopImmediatePropagation(); if (event) { this.focus(); } this._previousDataItem = undefined; this.selectionService.resetSelection([]); this.clearValueSubject.next(); this._filtering = false; } /** * @hidden */ writeValue(value) { this.value = value === null ? undefined : value; } /** * @hidden */ registerOnChange(fn) { this.onChangeCallback = fn; } /** * @hidden */ registerOnTouched(fn) { this.onTouchedCallback = fn; } /** * @hidden */ setDisabledState(isDisabled) { this.cdr.markForCheck(); this.disabled = isDisabled; } /** * @hidden */ get selectButtonClasses() { return `${this.size ? getSizeClass('button', this.size) : ''} ${this.fillMode ? 'k-button-' + this.fillMode : ''} ${this.fillMode ? 'k-button-' + this.fillMode + '-base' : ''} `; } /** * @hidden */ onResize() { const currentWindowSize = this.adaptiveService.size; if (this.isAdaptiveModeEnabled && this.windowSize !== currentWindowSize) { if (this.isOpen) { this.togglePopup(false); } this.windowSize = currentWindowSize; this.cdr.detectChanges(); } if (this.isOpen && !this.isActionSheetExpanded) { const popupWrapper = this.popupRef.popupElement; const { min, max } = this.popupWidth; popupWrapper.style.minWidth = min; popupWrapper.style.width = max; } } verifySettings() { if (!isDevMode()) { return; } if (this.valuePrimitive === true && isPresent(this.value) && typeof this.value === "object") { throw new Error(ComboBoxMessages.primitive); } if (this.valuePrimitive === false && isPresent(this.value) && typeof this.value !== "object") { throw new Error(ComboBoxMessages.object); } const valueOrText = !isPresent(this.valueField) !== !isPresent(this.textField); if (valueOrText) { throw new Error(ComboBoxMessages.textAndValue); } if (this.virtual && isNaN(this.virtual.itemHeight)) { throw new Error(ComboBoxMessages.noItemHeight); } } setState() { // Filtering in process, do nothing. if (this._filtering) { return; } const value = this.value; const valueField = this.valueField; const resolved = this.findDataItem({ valueField, value }); if (isPresent(resolved.index) && resolved.index !== -1) { this.updateState({ dataItem: resolved.dataItem, confirm: true }); this.resetSelection(resolved.index); } else if (isPresent(value) && this.allowCustom) { this.updateState({ dataItem: value }); this.resetSelection(-1); } else if (this._previousDataItem && this.value) { this.updateState({ dataItem: this._previousDataItem }); this.resetSelection(); } else { this.clearState(); this.resetSelection(-1); } } updateState({ dataItem, confirm = false }) { this.dataItem = dataItem; this.text = getter(dataItem, this.textField); if (confirm) { this._previousDataItem = dataItem; } } clearState() { this.text = undefined; this.dataItem = undefined; } resetSelection(index) { const clear = !isPresent(index) || index < 0; this.selectionService.resetSelection(clear ? [] : [index]); this.selectionService.focused = index; } firstFocusableIndex(index) { const maxIndex = this.data.length - 1; if (this.disabledItemsService.isIndexDisabled(index)) { return (index < maxIndex) ? this.firstFocusableIndex(index + 1) : undefined; } else { return index; } } findIndexPredicate(text) { if (this.dataService.grouped) { return (item) => { let itemText = getter(item.value, this.textField); itemText = !isPresent(itemText) ? "" : itemText.toString().toLowerCase(); return itemText.startsWith(text.toLowerCase()); }; } else { return (item) => { let itemText = getter(item, this.textField); itemText = !isPresent(itemText) ? "" : itemText.toString().toLowerCase(); return itemText.startsWith(text.toLowerCase()); }; } } findDataItem({ valueField, value }) { const result = { dataItem: null, index: -1 }; const comparer = (element) => { const dataItem = this.dataService.grouped ? element.value : element; return getter(dataItem, valueField) === getter(value, valueField); }; const index = this.dataService.findIndex(comparer); result.dataItem = this.dataService.itemAt(index); result.index = index; return result; } search(text, startFrom = 0) { const index = this.findIndex(text, startFrom); if (this.disabledItemsService.isIndexDisabled(index)) { if (index + 1 < this.dataService.itemsCount) { this.search(text, index + 1); } else { this.selectionService.focus(-1); } } else { this.selectionService.focus(index); if (this.suggest) { this.suggestedText = getter(this.dataService.itemAt(index), this.textField); } } } /** * @hidden */ onAdaptiveTextBoxChange(text) { if (this.filterable && this.filterText !== text) { this.filterText = text; this.filterChange.emit(text); } else { this.search(text); } } /** * @hidden */ getSuggestion() { const hasSelected = !!this.selectionService.selected.length; const shouldSuggest = this.suggest && !this.backspacePressed && this.suggestedText && this.text; if (!hasSelected && shouldSuggest && this.suggestedText.toLowerCase().startsWith(this.text.toLowerCase())) { return this.suggestedText; } else { this.suggestedText = undefined; } } navigate(index) { if (this.dataService.itemsCount === 0) { return; } this.text = getter(this.dataService.itemAt(index), this.textField); this.selectionService.select(index); } /** * @hidden */ handleNavigate(event) { const hasSelected = isPresent(this.selectionService.selected[0]); const focused = isNaN(this.selectionService.focused) ? this.firstFocusableIndex(0) : this.selectionService.focused; let offset = 0; if (this.disabled || this.readonly) { return; } if (event.keyCode === Keys.Home || event.keyCode === Keys.End) { return; } if (!hasSelected) { if (event.keyCode === Keys.ArrowDown) { offset = -1; } else if (event.keyCode === Keys.ArrowUp) { offset = 1; } } const action = this.navigationService.process({ current: offset + focused, max: this.dataService.itemsCount - 1, min: 0, originalEvent: event }); if (action !== NavigationAction.Undefined && action !== NavigationAction.Left && action !== NavigationAction.Right && action !== NavigationAction.Backspace && action !== NavigationAction.Delete && action !== NavigationAction.PageDown && action !== NavigationAction.PageUp && ((action === NavigationAction.Enter && this.isOpen) || action !== NavigationAction.Enter)) { event.preventDefault(); } if (action === NavigationAction.Tab && this.isActionSheetExpanded) { event.stopImmediatePropagation(); this.togglePopup(false); } } handleEnter() { const text = this.text; const focused = this.selectionService.focused; const hasFocused = isPresent(focused) && focused !== -1; const previousText = getter(this._previousDataItem, this.textField) || ""; const focusedItemText = getter(this.dataService.itemAt(focused), this.textField); const textHasChanged = text !== previousText; this.togglePopup(false); this._filtering = false; if (this.allowCustom && textHasChanged) { if (text === focusedItemText || this.useSuggestion()) { this.selectionService.change(focused); } else { this.change(text, true); } } if (!this.allowCustom) { if (hasFocused) { this.selectionService.change(focused); } else if (textHasChanged) { this.change(text, true); } } } /** * @hidden */ handleFocus() { this.zone.run(() => { if (!this.isFocused && hasObservers(this.onFocus)) { this.onFocus.emit(); } this.isFocused = true; }); } /** * @hidden */ handleBlur() { if (!this.isActionSheetExpanded) { this._filtering = false; this.searchbar.input.nativeElement.scrollLeft = 0; // Firefox doesn't auto-scroll to the left on blur like other browsers this.isFocused = false; const unresolvedSelection = getter(this.dataItem, this.valueField) !== getter(this.value, this.valueField); const currentText = this.searchbar.value; const textHasChanged = currentText !== (getter(this.dataItem, this.textField) || ''); const valueHasChanged = unresolvedSelection || textHasChanged; const runInZone = valueHasChanged || hasObservers(this.onBlur) || hasObservers(this.close) || isUntouched(this.wrapper.nativeElement); if (runInZone) { this.zone.run(() => { if (valueHasChanged) { const lowerCaseMatch = isPresent(this.focusedItemText) && this.focusedItemText.toLowerCase() === currentText.toLowerCase(); if (lowerCaseMatch || unresolvedSelection) { this.selectionService.change(this.selectionService.focused); } else { this.change(currentText, true); } } this.onBlur.emit(); this.onTouchedCallback(); this.togglePopup(false); }); } else { this.togglePopup(false); } } } /** * @hidden */ handleInputBlur() { if (!this.isActionSheetExpanded) { this._filtering = false; this.searchbar.input.nativeElement.scrollLeft = 0; // Firefox doesn't auto-scroll to the left on blur like other browsers const unresolvedSelection = getter(this.dataItem, this.valueField) !== getter(this.value, this.valueField); const currentText = this.searchbar.value; const textHasChanged = currentText !== (getter(this.dataItem, this.textField) || ''); const valueHasChanged = unresolvedSelection || textHasChanged; const runInZone = valueHasChanged || hasObservers(this.onBlur) || hasObservers(this.close) || isUntouched(this.wrapper.nativeElement) || this.formControl?.updateOn === 'blur'; if (runInZone) { this.zone.run(() => { if (valueHasChanged) { const lowerCaseMatch = isPresent(this.focusedItemText) && this.focusedItemText.toLowerCase() === currentText.toLowerCase(); if (lowerCaseMatch || unresolvedSelection) { this.selectionService.change(this.selectionService.focused); } else { this.change(currentText, true); } } this.inputBlur.emit(); this.onTouchedCallback(); this.togglePopup(false); }); } else { this.togglePopup(false); } } } /** * @hidden */ handleEscape() { this.isOpen ? this.togglePopup(false) : this.clearValue(); // clear the focus only if the focused item is not selected const hasSelected = this.selectionService.selected.length > 0; if (!hasSelected) { this.suggestedText = null; this.selectionService.focused = -1; } hasObservers(this.escape) && this.escape.emit(); } /** * @hidden */ handleNavigationOpen() { this.restoreItemFocus(); this.togglePopup(true); } /** * @hidden */ searchBarChange(text) { const currentTextLength = this.text ? this.text.length : 0; this.backspacePressed = (text.length < currentTextLength) ? true : false; this.text = text; // Reset the selection prior to filter. If a match is present, it will be resolved. If a match is not present, it is not needed. this.selectionService.resetSelection([]); this.togglePopup(true); this._filtering = true; if (this.filterable && this.filterText !== text) { this.filterText = text; this.filterChange.emit(text); } else { this.search(text); } } /** * @hidden */ handleInputFocus() { this.handleFocus(); if (hasObservers(this.inputFocus)) { this.zone.run(() => this.inputFocus.emit()); } } /** * @hidden */ pageChange(event) { const virtual = this.virtual; virtual.skip = event.skip; } change(candidate, isCustom = false) { if (isCustom) { this.customValueSubject.next(candidate); } else { this.valueSubject.next(candidate); if (this.isActionSheetExpanded) { this.togglePopup(false); } } } emitValueChange() { this.onChangeCallback(this.value); this.valueChange.emit(this.value); this._previousDataItem = this.dataItem; } /** * @hidden */ selectClick() { if (!touchEnabled) { this.searchbar.focus(); this.isFocused = true; }