UNPKG

@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
/**----------------------------------------------------------------------------------------- * 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`&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 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