@progress/kendo-angular-dropdowns
Version:
A wide variety of native Angular dropdown components including AutoComplete, ComboBox, DropDownList, DropDownTree, MultiColumnComboBox, MultiSelect, and MultiSelectTree
1,377 lines • 84.3 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { Component, Renderer2, forwardRef, ElementRef, Input, Output, HostBinding, EventEmitter, ContentChild, ViewChild, ViewContainerRef, isDevMode, NgZone, TemplateRef, ChangeDetectorRef, HostListener, Injector } from '@angular/core';
import { NG_VALUE_ACCESSOR, NgControl, ReactiveFormsModule, FormsModule } from '@angular/forms';
import { validatePackage } from '@progress/kendo-licensing';
import { packageMetadata } from '../package-metadata';
import { merge, interval, Subscription } from 'rxjs';
import { concatMap, filter, map, skipWhile, take, takeUntil, tap } from 'rxjs/operators';
import { isDocumentAvailable, KendoInput, hasObservers, anyChanged, isChanged, EventsOutsideAngularDirective, ResizeSensorComponent, Keys, TemplateContextDirective } from '@progress/kendo-angular-common';
import { AdaptiveService } from '@progress/kendo-angular-utils';
import { isPresent, guid, getter, shuffleData, sameCharsOnly, matchText, isUntouched, inDropDown, getSizeClass, getRoundedClass, getFillModeClass, isTruthy, updateActionSheetAdaptiveAppearance, setListBoxAriaLabelledBy, setActionSheetTitle, animationDuration } from '../common/util';
import { SelectionService } from '../common/selection/selection.service';
import { NavigationService, NavigationEvent } from '../common/navigation/navigation.service';
import { ItemTemplateDirective } from '../common/templates/item-template.directive';
import { GroupTemplateDirective } from '../common/templates/group-template.directive';
import { FixedGroupTemplateDirective } from '../common/templates/fixed-group-template.directive';
import { ValueTemplateDirective } from '../common/templates/value-template.directive';
import { HeaderTemplateDirective } from '../common/templates/header-template.directive';
import { FooterTemplateDirective } from '../common/templates/footer-template.directive';
import { NoDataTemplateDirective } from '../common/templates/no-data-template.directive';
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 { DropDownListMessages } from '../common/constants/error-messages';
import { DisabledItemsService } from '../common/disabled-items/disabled-items.service';
import { DataService } from '../common/data.service';
import { FilterableComponent } from '../common/filtering/filterable-component';
import { ListComponent } from '../common/list.component';
import { normalizeVirtualizationSettings } from '../common/models/virtualization-settings';
import { caretAltDownIcon, searchIcon, xIcon } from '@progress/kendo-svg-icons';
import { ResponsiveRendererComponent } from '../common/action-sheet.component';
import { SelectableDirective } from '../common/selection/selectable.directive';
import { FilterInputDirective } from '../common/filter-input.directive';
import { NgIf, NgClass, NgTemplateOutlet } from '@angular/common';
import { LocalizedMessagesDirective } from '../common/localization/localized-messages.directive';
import { IconWrapperComponent } from '@progress/kendo-angular-icons';
import { touchEnabled } from '@progress/kendo-common';
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";
import * as i8 from "@angular/forms";
/**
* @hidden
*/
export const DROPDOWNLIST_VALUE_ACCESSOR = {
multi: true,
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DropDownListComponent)
};
const DEFAULT_SIZE = 'medium';
const DEFAULT_ROUNDED = 'medium';
const DEFAULT_FILL_MODE = 'solid';
/**
* Represents the [Kendo UI DropDownList component for Angular]({% slug overview_ddl %}).
*
* @example
* ```ts
* _@Component({
* selector: 'my-app',
* template: `
* <kendo-dropdownlist [data]="listItems">
* </kendo-dropdownlist>
* `
* })
* class AppComponent {
* public listItems: Array<string> = ["Item 1", "Item 2", "Item 3", "Item 4"];
* }
* ```
*/
export class DropDownListComponent {
wrapper;
localization;
popupService;
selectionService;
navigationService;
disabledItemsService;
dataService;
_zone;
renderer;
cdr;
injector;
adaptiveService;
/**
* @hidden
*/
touchEnabled = touchEnabled;
/**
* @hidden
*/
animationDuration = animationDuration;
/**
* @hidden
*/
xIcon = xIcon;
/**
* @hidden
*/
searchIcon = searchIcon;
/**
* @hidden
*/
caretAltDownSVGIcon = caretAltDownIcon;
/**
* @hidden
*/
customIconClass;
/**
* @hidden
*/
responsiveRendererComponent;
/**
* @hidden
*/
get actionSheet() {
return this.responsiveRendererComponent?.actionSheet;
}
/**
* @hidden
*/
get actionSheetSearchBar() {
return this.responsiveRendererComponent?.actionSheetSearchBar;
}
get width() {
const wrapperWidth = isDocumentAvailable() ? this.wrapper.nativeElement.offsetWidth : 0;
const width = this.popupSettings.width || wrapperWidth;
const minWidth = isNaN(wrapperWidth) ? wrapperWidth : `${wrapperWidth}px`;
const maxWidth = isNaN(width) ? width : `${width}px`;
return { min: minWidth, max: maxWidth };
}
get height() {
const popupHeight = this.popupSettings.height;
return isPresent(popupHeight) ? `${popupHeight}px` : 'auto';
}
get widgetTabIndex() {
if (this.disabled) {
return undefined;
}
const providedTabIndex = Number(this.tabIndex);
const defaultTabIndex = 0;
return !isNaN(providedTabIndex) ? providedTabIndex : defaultTabIndex;
}
get ariaActivedescendant() {
if (!isPresent(this.dataItem) || !this.isOpen) {
return;
}
return this.optionPrefix + "-" + this.selectionService.focused;
}
get appendTo() {
const { appendTo } = this.popupSettings;
if (!appendTo || appendTo === 'root') {
return undefined;
}
return appendTo === 'component' ? this.container : appendTo;
}
/**
* @hidden
*/
onFilterChange(text) {
if (this.filterable) {
this.filterChange.emit(text);
}
}
/**
* @hidden
*/
get ariaLive() {
return this.filterable ? 'polite' : 'off';
}
/**
* 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
*/
icon;
/**
* @hidden
*/
svgIcon;
/**
* Sets and gets the loading state of the DropDownList.
*/
loading;
/**
* Sets the data of the DropDownList.
*
* > 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();
}
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 DropDownList.
* 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 source are ignored.
*/
set value(newValue) {
if (!isPresent(newValue)) {
this._previousDataItem = undefined;
}
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;
/**
* 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.
*/
subtitle = '';
/**
* @hidden
*/
get isAdaptiveModeEnabled() {
return this.adaptiveMode === 'auto';
}
/**
* @hidden
*/
windowSize = 'large';
/**
* @hidden
*/
get isActionSheetExpanded() {
return this.actionSheet?.expanded;
}
/**
* @hidden
*/
get isAdaptive() {
return this.isAdaptiveModeEnabled && this.windowSize !== 'large';
}
/**
* Configures the popup of the DropDownList.
*
* The available options are:
* - `animate: Boolean`—Controls the popup animation. By default, the open and close animations are enabled.
* - `width: Number | String`—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`—Sets the height of the popup container.
* - `popupClass: String`—Specifies a list of CSS classes that are used to style the popup.
* - `appendTo: "root" | "component" | ViewContainerRef`—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 the text of the default empty item. The type of the defined value has to match the data type.
*/
defaultItem;
/**
* Sets the disabled state of the component. To learn how to disable the component in reactive forms, refer to the article on [Forms Support](slug:formssupport_ddl#toc-managing-the-dropdownlist-disabled-state-in-reactive-forms).
*/
disabled;
/**
* Defines a Boolean function that is executed for each data item in the component
* ([see examples]({% slug disableditems_ddl %})). 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;
/**
* Enables the [filtering]({% slug filtering_ddl %}) functionality of the DropDownList.
*/
filterable = false;
/**
* Enables the [virtualization]({% slug virtualization_ddl %}) functionality.
*/
set virtual(settings) {
this._virtualSettings = normalizeVirtualizationSettings(settings);
}
get virtual() {
return this._virtualSettings;
}
/**
* Enables a case-insensitive search. When filtration is disabled, use this option.
*/
ignoreCase = true;
/**
* Sets the delay before an item search is performed. When filtration is disabled, use this option.
*/
delay = 500;
/**
* Specifies the type of the selected value
* ([more information and example]({% slug valuebinding_ddl %}#toc-primitive-values-from-object-fields)).
* If set to `true`, the selected value has to be of a primitive value.
*/
set valuePrimitive(isPrimitive) {
this._valuePrimitive = isPrimitive;
}
get valuePrimitive() {
if (!isPresent(this._valuePrimitive)) {
return !isPresent(this.valueField);
}
return this._valuePrimitive;
}
/**
* 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;
}
/**
* 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('picker', this.size));
if (size !== 'none') {
this.renderer.addClass(this.wrapper.nativeElement, getSizeClass('picker', 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 = rounded;
}
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('picker', this.fillMode));
if (fillMode !== 'none') {
this.renderer.addClass(this.wrapper.nativeElement, getFillModeClass('picker', newFillMode));
}
this._fillMode = newFillMode;
}
get fillMode() {
return this._fillMode;
}
/**
* Toggles the left and right arrow keys navigation functionality.
* @hidden
*/
leftRightArrowsNavigation = true;
/**
* Fires each time the value is changed ([see example](slug:events_ddl)).
*/
valueChange = new EventEmitter();
/**
* Fires each time the user types in the input field
* ([see example](slug:events_ddl)).
* You can filter the source based on the passed filtration value.
* 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.
*/
filterChange = new EventEmitter();
/**
* Fires each time the item selection is changed
* ([see example](slug:events_ddl)).
*/
selectionChange = new EventEmitter();
/**
* Fires each time the popup is about to open
* ([see example]({% slug openstate_ddl %}#toc-preventing-opening-and-closing)).
* 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
* ([see example]({% slug openstate_ddl %}#toc-preventing-opening-and-closing)).
* 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 DropDownList.
*/
onFocus = new EventEmitter();
/**
* Fires each time the DropDownList gets blurred.
*/
onBlur = new EventEmitter();
itemTemplate;
groupTemplate;
fixedGroupTemplate;
valueTemplate;
headerTemplate;
footerTemplate;
noDataTemplate;
container;
popupTemplate;
optionsList;
/**
* @hidden
*/
blurComponent(event) {
if (event.target !== this.wrapper.nativeElement) {
return;
}
event.stopImmediatePropagation();
this.hostElementBlurred.emit();
}
/**
* @hidden
*/
blurFilterInput() {
this.filterBlurred.emit();
}
/**
* @hidden
*/
focusComponent(event) {
if (event.target !== this.wrapper.nativeElement) {
return;
}
event.stopImmediatePropagation();
this.hostElementFocused.emit();
if (!this.isFocused) {
this.isFocused = true;
if (hasObservers(this.onFocus)) {
this._zone.run(() => {
this.onFocus.emit();
});
}
}
}
/**
* @hidden
*/
onResize() {
const currentWindowSize = this.adaptiveService.size;
if (this.isAdaptiveModeEnabled && this.windowSize !== currentWindowSize) {
if (this.isOpen) {
this.togglePopup(false);
}
this.windowSize = currentWindowSize;
}
if (this.isOpen && !this.isActionSheetExpanded) {
const popupWrapper = this.popupRef.popupElement;
const { min, max } = this.width;
popupWrapper.style.minWidth = min;
popupWrapper.style.width = max;
}
}
hostClasses = true;
get isDisabledClass() {
return this.disabled || null;
}
get isLoading() {
return this.loading;
}
/**
* @hidden
*/
focusableId = `k-${guid()}`;
get dir() {
return this.direction;
}
get hostTabIndex() {
return this.widgetTabIndex;
}
get readonlyClass() {
return this.readonly;
}
get readonlyAttr() {
return this.readonly ? '' : null;
}
get isBusy() {
return this.isLoading;
}
role = 'combobox';
haspopup = 'listbox';
get hostAriaInvalid() {
return this.formControl?.invalid ? true : null;
}
/**
* @hidden
*/
keydown(event, input) {
if (input) {
event.stopPropagation();
}
const firstIndex = isPresent(this.defaultItem) ? -1 : 0;
const focused = isNaN(this.selectionService.focused) ? this.firstFocusableIndex(firstIndex) : this.selectionService.focused;
let offset = 0;
if (this.disabled || this.readonly) {
return;
}
const isHomeEnd = event.keyCode === Keys.Home || event.keyCode === Keys.End;
const isFilterFocused = this.filterable && this.isFocused && this.isOpen;
if (isFilterFocused && isHomeEnd) {
return;
}
const hasSelected = isPresent(this.selectionService.selected[0]);
const focusedItemNotSelected = isPresent(this.selectionService.focused) && !this.selectionService.isSelected(this.selectionService.focused);
if (!hasSelected || focusedItemNotSelected) {
if (event.keyCode === Keys.ArrowDown || event.keyCode === Keys.ArrowRight && this.leftRightArrowsNavigation) {
offset = -1;
}
else if (event.keyCode === Keys.ArrowUp || event.keyCode === Keys.ArrowLeft && this.leftRightArrowsNavigation) {
offset = 1;
}
}
const eventData = event;
const action = this.navigationService.process({
current: focused + offset,
max: this.dataService.itemsCount - 1,
min: this.defaultItem ? -1 : 0,
originalEvent: eventData,
openOnSpace: !this.isOpen,
closeOnSpace: this.isOpen && !input && !(event.target instanceof HTMLInputElement)
});
const leftRightKeys = (action === NavigationAction.Left) || (action === NavigationAction.Right) && this.leftRightArrowsNavigation;
if (action !== NavigationAction.Undefined &&
action !== NavigationAction.Tab &&
action !== NavigationAction.Backspace &&
action !== NavigationAction.Delete &&
action !== NavigationAction.PageDown &&
action !== NavigationAction.PageUp &&
action !== NavigationAction.SelectAll &&
!(leftRightKeys && this.filterable) &&
action !== NavigationAction.Enter //enter when popup is opened is handled before `handleEnter`
) {
eventData.preventDefault();
}
if (action === NavigationAction.Tab && this.isActionSheetExpanded) {
this.togglePopup(false);
}
}
/**
* @hidden
*/
keypress(event) {
if (this.disabled || this.readonly || this.filterable) {
return;
}
this.onKeyPress(event);
}
/**
* @hidden
*/
click() {
if (!this.isActionSheetExpanded) {
this.focus();
this.togglePopup(!this.isOpen);
}
}
groupIndices = [];
optionPrefix = `k-${guid()}`;
valueLabelId;
filterText = '';
listBoxId = `k-${guid()}`;
subs = new Subscription();
_isFocused = false;
set isFocused(isFocused) {
this.renderer[isFocused ? 'addClass' : 'removeClass'](this.wrapper.nativeElement, 'k-focus');
this._isFocused = isFocused;
}
get isFocused() {
return this._isFocused;
}
direction;
dataItem;
popupRef;
onTouchedCallback = (_) => { };
onChangeCallback = (_) => { };
popupMouseDownHandler;
word = "";
last = "";
typingTimeout;
filterFocused = new EventEmitter();
filterBlurred = new EventEmitter();
hostElementFocused = new EventEmitter();
hostElementBlurred = new EventEmitter();
touchstartDisposeHandler;
_value;
_open = false;
_previousDataItem;
_valuePrimitive;
text;
_popupSettings = { animate: true };
_virtualSettings;
_size = 'medium';
_rounded = 'medium';
_fillMode = 'solid';
constructor(wrapper, localization, popupService, selectionService, navigationService, disabledItemsService, dataService, _zone, renderer, cdr, injector, adaptiveService) {
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.renderer = renderer;
this.cdr = cdr;
this.injector = injector;
this.adaptiveService = adaptiveService;
validatePackage(packageMetadata);
this.direction = localization.rtl ? 'rtl' : 'ltr';
this.data = [];
this.subscribeEvents();
this.subscribeTouchEvents();
this.subscribeFocusEvents();
this.popupMouseDownHandler = this.onMouseDown.bind(this);
}
ngOnInit() {
this.renderer.removeAttribute(this.wrapper.nativeElement, "tabindex");
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', 'false');
if (this.ariaActivedescendant) {
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-activedescendant', this.ariaActivedescendant);
}
this.subs.add(this.localization
.changes.subscribe(({ rtl }) => {
this.direction = rtl ? 'rtl' : 'ltr';
this.cdr.detectChanges();
}));
this.assignAriaDescribedBy();
this.setComponentClasses();
}
ngAfterViewInit() {
this.windowSize = this.adaptiveService.size;
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');
}
}
/**
* @hidden
* Used by the TextBoxContainer to determine if the component is empty.
*/
isEmpty() {
const value = this.value;
return !(value === 0 || value === false || value || this.defaultItem);
}
/**
* @hidden
*/
onFilterFocus() {
this.filterFocused.emit();
}
/**
* @hidden
*/
ngOnDestroy() {
this.destroyPopup();
this.subs.unsubscribe();
this.unSubscribeFocusEvents();
if (this.touchstartDisposeHandler) {
this.touchstartDisposeHandler();
}
}
/**
* @hidden
*/
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('defaultItem', changes, false)) {
this.disabledItemsService.defaultItem = this.defaultItem;
}
if (anyChanged(['textField', 'valueField', 'valuePrimitive', 'defaultItem', 'itemDisabled'], changes, false)) {
this.setState();
}
}
/**
* @hidden
*/
ngAfterContentChecked() {
this.verifySettings();
}
/**
* @hidden
*/
get formControl() {
const ngControl = this.injector.get(NgControl, null);
return ngControl?.control || null;
}
/**
* Focuses a specific item of the DropDownList based on a provided index.
* If there is a default item it is positioned at index -1.
* If null or invalid index is provided the focus will be removed.
*/
focusItemAt(index) {
const minIndex = isPresent(this.defaultItem) ? -1 : 0;
const isInRange = minIndex <= index && index < this.data.length;
if (isPresent(index) && isInRange && !this.disabledItemsService.isIndexDisabled(index)) {
this.selectionService.focus(index);
}
else {
this.selectionService.focus(null);
}
}
/**
* Focuses the DropDownList.
*/
focus() {
if (!this.disabled) {
this.wrapper.nativeElement.focus();
}
}
/**
* Blurs the DropDownList.
*/
blur() {
if (!this.disabled) {
this.wrapper.nativeElement.blur();
this.cdr.detectChanges();
}
}
/**
* Toggles the visibility of the popup or actionSheet
* ([see example]({% slug openstate_ddl %}#toc-setting-the-initially-opened-component)).
* If you use the `toggle` method to open or close the popup, the `open` and `close` events will not be fired.
*
* @param open - The state of the popup.
*/
toggle(open) {
// The Promise is required to open the popup on load.
// Otherwise, the "Expression has changed..." type error will be thrown.
Promise.resolve(null).then(() => {
const shouldOpen = isPresent(open) ? open : !this._open;
this._toggle(shouldOpen);
});
}
_toggle(open) {
this._open = open;
this.destroyPopup();
if (this.isActionSheetExpanded) {
this.closeActionSheet();
}
if (this._open) {
this.createPopup();
}
}
triggerPopupEvents(open) {
const eventArgs = new PreventableEvent();
if (open) {
this.open.emit(eventArgs);
}
else {
this.close.emit(eventArgs);
}
return eventArgs.isDefaultPrevented();
}
/**
* @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) {
if (!open && this.filterable && this.isFocused) {
this.focus();
}
this._toggle(open);
}
}
/**
* Returns the current open state. Returns `true` if the popup or actionSheet is open.
*/
get isOpen() {
return isTruthy(this._open || this.isActionSheetExpanded);
}
/**
* Resets the value of the DropDownList.
* 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;
}
/**
* @hidden
*/
messageFor(key) {
return this.localization.get(key);
}
/**
* @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
*/
get filterInputClasses() {
return `${this.size ? getSizeClass('input', this.size) : ''} ${this.fillMode ? 'k-input-' + this.fillMode : ''} ${this.rounded ? getRoundedClass(this.rounded) : ''}`;
}
/**
* @hidden
*/
get optionLabelSizeClass() {
return `${this.size ? getSizeClass('list', this.size) : ''}`;
}
/**
* @hidden
*/
get listContainerClasses() {
const containerClasses = ['k-list-container', 'k-dropdownlist-popup'];
if (this.popupSettings.popupClass) {
containerClasses.push(this.popupSettings.popupClass);
}
return containerClasses;
}
/**
* @hidden
*/
get isDisabledDefaultItem() {
return this.disabledItemsService.isItemDisabled(this.defaultItem);
}
/**
* @hidden
*/
getText() {
return this.text;
}
/**
* @hidden
*/
getDefaultItemText() {
return getter(this.defaultItem, this.textField);
}
createPopup() {
if (this.virtual) {
this.virtual.skip = 0;
}
this.windowSize = this.adaptiveService.size;
if (this.isAdaptive) {
this.openActionSheet();
return;
}
const horizontalAlign = this.direction === "rtl" ? "right" : "left";
const anchorPosition = { horizontal: horizontalAlign, vertical: "bottom" };
const popupPosition = { horizontal: horizontalAlign, vertical: "top" };
const appendToComponent = typeof this.popupSettings.appendTo === 'string' && this.popupSettings.appendTo === 'component';
this.popupRef = this.popupService.open({
anchor: this.wrapper,
anchorAlign: anchorPosition,
animate: this.popupSettings.animate,
appendTo: this.appendTo,
content: this.popupTemplate,
popupAlign: popupPosition,
popupClass: this.listContainerClasses,
positionMode: appendToComponent ? 'fixed' : 'absolute'
});
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', 'true');
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-controls', this.listBoxId);
const popupWrapper = this.popupRef.popupElement;
const { min, max } = this.width;
popupWrapper.addEventListener('mousedown', this.popupMouseDownHandler);
popupWrapper.style.minWidth = min;
popupWrapper.style.width = max;
popupWrapper.style.height = this.height;
popupWrapper.setAttribute('dir', this.direction);
if (!this.appendTo) {
this.renderer.setAttribute(popupWrapper, 'role', 'region');
this.renderer.setAttribute(popupWrapper, 'aria-label', this.messageFor('popupLabel'));
}
const listBox = popupWrapper.querySelector('ul.k-list-ul');
const ariaLabel = this.wrapper.nativeElement.getAttribute('aria-labelledby');
if (ariaLabel) {
listBox.setAttribute('aria-labelledby', ariaLabel);
}
this.subs.add(this.popupRef.popupOpen.subscribe(() => {
this.cdr.detectChanges();
setListBoxAriaLabelledBy(this.optionsList, this.wrapper, this.renderer);
this.setAriaactivedescendant();
this.optionsList.scrollToItem(this.selectionService.focused);
this.selectionService.focus(this.selectionService.focused);
this.opened.emit();
}));
this.subs.add(this.popupRef.popupClose.subscribe(() => {
this.closed.emit();
}));
if (!this.filterable) {
this.subs.add(this.popupRef.popupAnchorViewportLeave.subscribe(() => this.togglePopup(false)));
}
}
destroyPopup() {
if (this.popupRef) {
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', 'false');
this.renderer.removeAttribute(this.wrapper.nativeElement, 'aria-controls');
this.renderer.removeAttribute(this.wrapper.nativeElement, 'aria-activedescendant');
this.popupRef.popupElement
.removeEventListener('mousedown', this.popupMouseDownHandler);
this.popupRef.close();
this.popupRef = null;
}
}
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);
this.selectionService.resetSelection(clear ? [] : [index]);
this.selectionService.focused = clear ? this.firstFocusableIndex(0) : index;
}
onSelectionChange({ dataItem }) {
this.updateState({ dataItem });
this.selectionChange.emit(dataItem);
// reassigning the value label ID as aria-deascibedby forces firefox/nvda, forefox/jaws to read
// the new value when the popup is closed and the value is changed with the arrow keys (up/down)
this.assignAriaDescribedBy();
this.setAriaactivedescendant();
}
subscribeEvents() {
if (!isDocumentAvailable()) {
return;
}
// Item selection when the popup is open.
this.subs.add(this.selectionService.onSelect.pipe(filter(() => this.isOpen), map(this.itemFromEvent.bind(this)))
.subscribe(this.onSelectionChange.bind(this)));
// Item selection when the popup is closed | clicked | enter, and so on.
this.subs.add(merge(this.selectionService.onSelect.pipe(filter(() => !this.isOpen)), this.selectionService.onChange).pipe(map(this.itemFromEvent.bind(this)), tap(() => this.togglePopup(false)))
.subscribe(({ dataItem, value: newValue, newSelection }) => {
if (newSelection) {
this.onSelectionChange({ dataItem });
}
const shouldUsePrevious = !isPresent(dataItem) && this._previousDataItem;
const shouldUseNewValue = newValue !== getter(this.value, this.valueField);
if (shouldUsePrevious) {
this.updateState({ dataItem: this._previousDataItem });
this.resetSelection();
}
else if (shouldUseNewValue) {
this.value = this.valuePrimitive ? newValue : dataItem;
this._previousDataItem = dataItem;
this.emitChange(this.value);
}
this.clearFilter();
}));
this.subs.add(merge(this.navigationService.up, this.navigationService.down, this.navigationService.left.pipe(filter(() => this.leftRightArrowsNavigation), skipWhile(() => this.filterable)), this.navigationService.right.pipe(filter(() => this.leftRightArrowsNavigation), skipWhile(() => this.filterable)), this.navigationService.home, this.navigationService.end)
.pipe(filter((event) => !isNaN(event.index)))
.subscribe((event) => this.selectionService.select(event.index)));
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.open.subscribe(() => this.togglePopup(true)));
this.subs.add(this.navigationService.close.subscribe(() => {
this.togglePopup(false);
this.focus();
}));
this.subs.add(this.navigationService.enter
.pipe(tap((event) => event.originalEvent.preventDefault()))
.subscribe(this.handleEnter.bind(this)));
this.subs.add(this.navigationService.esc
.subscribe(this.handleEscape.bind(this)));
this.subs.add(this.filterBlurred.pipe(concatMap(() => interval(10).pipe(take(1), takeUntil(this.hostElementFocused))))
.subscribe(() => {
this.hostElementBlurred.emit();
}));
this._zone.runOutsideAngular(() => {
this.subs.add(merge(this.hostElementBlurred.pipe(concatMap(() => interval(10).pipe(take(1), takeUntil(this.filterFocused)))), this.navigationService.tab).pipe(tap(event => event instanceof NavigationEvent && this.focus()), filter(() => this.isFocused))
.subscribe(() => this.componentBlur()));
});
}
setAriaactivedescendant() {
if (this.ariaActivedescendant) {
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-activedescendant', this.ariaActivedescendant);
const searchInput = this.popupRef?.popupElement.querySelector('input[role="searchbox"]') || this.actionSheetSearchBar?.input.nativeElement;
if (searchInput) {
this.renderer.setAttribute(searchInput, 'aria-activedescendant', this.ariaActivedescendant);
}
}
}
subscribeTouchEvents() {
if (!isDocumentAvailable() || !this.touchEnabled) {
return;
}
this._zone.runOutsideAngular(() =>
// Roll up DropDownList on iOS when tapped outside
this.touchstartDisposeHandler = this.renderer.listen(document, 'touchstart', (e) => {
const target = e.target;
if (this.isFocused && !inDropDown(this.wrapper, target, this.popupRef)) {
this._zone.run(() => {
if (this.filterFocused) {
// Close popup if filter is focused
this.togglePopup(false);
}
this.blur();
});
}
}));
}
subscribeFocusEvents() {
if (isDocumentAvailable()) {
this.focusComponent = this.focusComponent.bind(this);
this.blurComponent = this.blurComponent.bind(this);
this._zone.runOutsideAngular(() => {
const useCapture = true;
document.addEventListener('focus', this.focusComponent, useCapture);
document.addEventListener('blur', this.blurComponent, useCapture);
});
}
}
unSubscribeFocusEvents() {
if (isDocumentAvailable()) {
const useCapture = true;
document.removeEventListener('focus', this.focusComponent, useCapture);
document.removeEventListener('blur', this.blurComponent, useCapture);
}
}
itemFromEvent(event) {
const index = event.indices[0];
let dataItem = this.dataService.itemAt(index);
dataItem = isPresent(dataItem) ? dataItem : this.currentOrDefault(index);
const value = getter(dataItem, this.valueField);
const newSelection = event.newSelection;
return {
dataItem,
index,
newSelection,
value
};
}
currentOrDefault(selectedIndex) {
const defaultItemIndex = -1;
if (isPresent(this.dataItem) && selectedIndex !== defaultItemIndex) {
return this.dataItem;
}
else {
return this.defaultItem;
}
}
firstFocusableIndex(index) {
const maxIndex = this.dataService.itemsCount - 1;
if (this.disabledItemsService.isIndexDisabled(index)) {
return (index < maxIndex) ? this.firstFocusableIndex(index + 1) : undefined;
}
else {
return index;
}
}
handleEnter() {
if (this.isOpen) {
this.selectionService.change(this.selectionService.focused);
this.focus();
}
else {
this.togglePopup(true);
}
}
handleEscape() {
if (isPresent(this.selectionService.selected[0])) {
this.selectionService.change(this.selectionService.selected[0]);
}
else {
this.togglePopup(false);
this.clearFilter();
}
this.focus();
}
clearFilter() {
if (!(this.filterable && this.filterText)) {
return;
}
this.filterText = "";
this.cdr.markForCheck();
this.filterChange.emit(this.filterText);
}
verifySettings() {
if (!isDevMode()) {
return;
}
if (this.defaultItem && this.valueField && typeof this.defaultItem !== "object") {
throw new Error(DropDownListMessages.defaultItem);
}
if (this.valuePrimitive === true && isPresent(this.value) && typeof this.value === "object") {
throw new Error(DropDownListMessages.primitive);
}
if (this.valuePrimitive === false && isPresent(this.value) && typeof this.value !== "object") {
throw new Error(DropDownListMessages.object);
}
const valueOrText = !isPresent(this.valueField) !== !isPresent(this.textField);
if (valueOrText) {
throw new Error(DropDownListMessages.textAndValue);
}
}
componentBlur() {
if (!this.isActionSheetExpanded) {
this.isFocused = false;
const selectionPresent = isPresent(this.selectionService.selected[0]);
const valueHasChanged = selectionPresent && getter(this.value, this.valueField) !== getter(this.dataService.itemAt(this.selectionService.selected[0]), this.valueField);
if (valueHasChanged ||
hasObservers(this.close) ||
hasObservers(this.onBlur) ||
hasObservers(this.filterChange) ||
isUntouched(this.wrapper.nativeElement) ||
this.formControl?.updateOn === 'blur') {
this._zone.run(() => {
if (valueHasChanged) {
this.selectionService.change(this.selectionService.selected[0]);
}
this.togglePopup(false);
this.clearFilter();
this.onBlur.emit();
this.onTouchedCallback();
});
}
else {
this.togglePopup(false);
//this is needed for Ang 18 not to throw ng0100 error when closing the popup
//the component could be refactored using kendoDropDownSharedEvents directive
//once we are able to debug against Angular 18
this.cdr.markForCheck();
}
}
}
/**
* @hidden
*/
onMouseDown(event) {
const tagName = event.target.tagName.toLowerCase();
if (tagName !== "input") {
event.preventDefault();
}
}
onKeyPress(event) {
if (event.which === 0 || event.keyCode === Keys.Enter) {
return;
}
let character = String.fromCharCode(event.charCode || event.keyCode);
if (this.ignoreCase) {
character = character.toLowerCase();
}
if (character === " ") {
event.preventDefault();
}
this.word += character;
this.last = character;
this.search();
}
search() {
clearTimeout(this.typingTimeout);
if (!this.filterable) {
this.typingTimeout = setTimeout(() => {
this.word = "";
}, this.delay);
this.selectNext();
}
}
selectNext() {
let data = this.dataService
.filter((item) => isPresent(item) && !item.header && !this.disabledItemsService.isItemDisabled(item))
.map((item) => {
if (this.dataService.grouped) {
return { item: item.value, itemIndex: item.offsetIndex };
}
return { item: item, itemIndex: this.dataService.indexOf(item) };
});
const isInLoop = sameCharsOnly(this.word, this.last);
let dataLength = data.length;
const hasSelected = !isNaN(this.selectionService.selected[0]);
let startIndex = !hasSelected ? 0 : this.selectionService.selected[0];
let text, index, defaultItem;
if (this.defaultItem && !this.disabledItemsService.isItemDisabled(this.defaultItem)) {
defaultItem = { item: this.defaultItem, itemIndex: -1 };
dataLength += 1;
startIndex += 1;
}
startIndex += isInLoop && hasSelected ? 1 : 0;
data = shuffleData(data, startIndex, defaultItem);
index = 0;
for (; index < dataLength; index++) {
text = getter(data[index].item, this.textField);
const loopMatch = Boolean(isInLoop && matchText(text, this.last, this.ignoreCase));
const nextMatch = Boolean(matchText(text, this.word, this.ignoreCase));
if (loopMatch || nextMatch) {
index = data[index].itemIndex;
break;
}
}
if (index !== dataLength) {
this.navigate(index);
}
}
emitChange(value) {
this.onChangeCallback(value);
this.valueChange.emit(value);
}
navigate(index) {
this.selectionService.select(index);
}
findDataItem({ valueField, value }) {
const result = {
dataItem: null,
index: -1
};
const prop = dataItem => getter(dataItem, valueField);
let comparer;
if (this.dataService.grouped) {
comparer = (element) => {
return prop(element.value) === prop(value);
};
}
else {
comparer = (element) => {
return prop(element) === prop(value);
};
}
const index = this.dataService.findIndex(comparer);
result.dataItem = this.dataService.itemAt(index);
result.index = index;
return result;
}
setState() {
const value = this.value;
const valueField = this.va