UNPKG

@progress/kendo-angular-dropdowns

Version:

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

1,354 lines (1,353 loc) 76.7 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, HostBinding, Input, Output, NgZone, Renderer2, TemplateRef, ViewChild, ChangeDetectorRef, ContentChild, forwardRef, ViewContainerRef, isDevMode, Injector } from '@angular/core'; import { anyChanged, EventsOutsideAngularDirective, guid, hasObservers, isChanged, isDocumentAvailable, KendoInput, Keys, ResizeSensorComponent, TemplateContextDirective } from '@progress/kendo-angular-common'; import { AdaptiveService } from '@progress/kendo-angular-utils'; import { PopupService } from '@progress/kendo-angular-popup'; import { TreeViewComponent, DataBoundComponent, ExpandableComponent, SelectDirective } from '@progress/kendo-angular-treeview'; import { getter, touchEnabled } from '@progress/kendo-common'; import { NgControl, NG_VALUE_ACCESSOR } from '@angular/forms'; import { DataService } from '../common/data.service'; import { DisabledItemsService } from '../common/disabled-items/disabled-items.service'; import { NavigationService } from '../common/navigation/navigation.service'; import { L10N_PREFIX, LocalizationService } from '@progress/kendo-angular-l10n'; import { SelectionService } from '../common/selection/selection.service'; import { PreventableEvent } from '../common/models/preventable-event'; import { animationDuration, getFillModeClass, getRoundedClass, getSearchableItems, getSizeClass, inDropDown, isArray, isLetter, isPresent, isTruthy, isUntouched, noop, setActionSheetTitle, updateActionSheetAdaptiveAppearance } from '../common/util'; import { NoDataTemplateDirective } from '../common/templates/no-data-template.directive'; import { of, Subject, Subscription } from 'rxjs'; import { debounceTime, take, tap } from 'rxjs/operators'; import { HeaderTemplateDirective } from '../common/templates/header-template.directive'; import { FooterTemplateDirective } from '../common/templates/footer-template.directive'; import { NodeTemplateDirective } from './templates/node-template.directive'; import { DropDownTreeMessages } from '../common/constants/error-messages'; import { ValueTemplateDirective } from '../common/templates/value-template.directive'; import { caretAltDownIcon, searchIcon, xIcon } from '@progress/kendo-svg-icons'; import { ResponsiveRendererComponent } from '../common/action-sheet.component'; 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 * as i0 from "@angular/core"; import * as i1 from "@progress/kendo-angular-popup"; import * as i2 from "../common/navigation/navigation.service"; import * as i3 from "@progress/kendo-angular-l10n"; import * as i4 from "@progress/kendo-angular-utils"; const DEFAULT_POPUP_SETTINGS = { animate: true }; const hasChildren = () => false; const fetchChildren = () => of([]); const itemDisabled = () => false; const isNodeVisible = () => true; const DEFAULT_SIZE = 'medium'; const DEFAULT_ROUNDED = 'medium'; const DEFAULT_FILL_MODE = 'solid'; const stopPropagation = (event) => event.stopImmediatePropagation(); /** * Represents the [Kendo UI DropDownTree component for Angular]({% slug overview_ddt %}). */ export class DropDownTreeComponent { injector; wrapper; popupService; navigationService; renderer; _zone; cdr; localization; adaptiveService; /** * @hidden */ icon; /** * @hidden */ svgIcon; /** * @hidden */ touchEnabled = touchEnabled; /** * @hidden */ animationDuration = animationDuration; /** * @hidden */ searchIcon = searchIcon; /** * @hidden */ caretAltDownIcon = caretAltDownIcon; /** * @hidden */ xIcon = xIcon; /** * @hidden */ responsiveRendererComponent; /** * @hidden */ get actionSheet() { return this.responsiveRendererComponent?.actionSheet; } /** * @hidden */ get actionSheetSearchBar() { return this.responsiveRendererComponent?.actionSheetSearchBar; } hostClasses = true; get isReadonly() { return this.readonly ? '' : null; } get hostAriaInvalid() { return this.formControl?.invalid ? true : null; } get isDisabled() { return this.disabled || null; } get isLoading() { return this.loading; } get isBusy() { return this.loading ? 'true' : null; } get hostAriaControls() { return this.isOpen ? this.treeViewId : undefined; } get id() { return this.focusableId; } direction; get hostTabIndex() { return this.tabindex; } role = 'combobox'; ariaHasPopup = 'tree'; get isAriaExpanded() { return this.isOpen; } get hostAriaAutocomplete() { return this.filterable ? 'list' : null; } noDataTemplate; headerTemplate; footerTemplate; nodeTemplate; valueTemplate; popupTemplate; container; set treeview(treeview) { if (treeview) { if (this.isFocused && !this.filterable || this.touchEnabled) { treeview.focus(); } // the treeview animations are initially disabled (we don't want expand animations during popup opening) // re-enables the animations for user interaction treeview.animate = true; this._treeview = treeview; } } get treeview() { return this._treeview; } filterInput; /** * Fires each time the popup is about to open * ([see example]({% slug openstate_ddt %})). * 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_ddt %})). * 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 when the user expands a node in the popup TreeView. */ nodeExpand = new EventEmitter(); /** * Fires when the user collapses a node in the popup TreeView. */ nodeCollapse = new EventEmitter(); /** * Fires each time the user focuses the DropDownTree. */ onFocus = new EventEmitter(); /** * Fires each time the DropDownTree gets blurred. */ onBlur = new EventEmitter(); /** * Fires each time the value is changed * ([see example](slug:events_ddt)). */ valueChange = new EventEmitter(); /** * Fires when the value of the built-in filter input element changes. */ filterChange = new EventEmitter(); /** * Sets and gets the loading state of the DropDownTree. */ loading; /** * 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 data of the DropDownTree. * * > The data has to be provided in an array-like list with objects. */ set data(data) { this._nodes = data; this.setState(); } get data() { return this._nodes; } /** * Sets the value of the DropDownTree. * It can either be of the primitive (string, numbers) or of the complex (objects) type. * To define the type, use the `valuePrimitive` option. * */ set value(newValue) { this._value = newValue; this.setState(); } get value() { return this._value; } /** * The fields of the data item that provide the text content of the nodes inside the * DropDownTree ([see example]({% slug databinding_ddt %})). If the `textField` * input is set to an array, each hierarchical level uses the field that corresponds * to the same index in the array, or the last item in the array. * * > The `textField` property can be set to point to a nested property value - e.g. `category.name`. */ textField; /** * The fields of the data item that provide the value of the nodes inside the * DropDownTree ([see example]({% slug databinding_ddt %})). If the `valueField` * input is set to an array, each hierarchical level uses the field that corresponds * to the same index in the array, or the last item in the array. * * > The `valueField` property can be set to point to a nested property value - e.g. `category.id`. */ valueField; /** * Sets the level in the data set where the value can be found when `valueField` is an Array. * The field serves to correctly allocate a data item used when the DropDownTree is initialized with a value. */ valueDepth; /** * A function which determines if a specific node has child nodes. */ hasChildren = hasChildren; /** * A function which provides the child nodes for a given parent node. */ fetchChildren = fetchChildren; /** * The hint which is displayed when the component is empty. */ placeholder = ""; /** * Configures the popup of the DropDownTree. * * 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. * - `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({}, DEFAULT_POPUP_SETTINGS, settings); } get popupSettings() { return this._popupSettings; } /** * Keeps the current `dataItem` object in order to resolve selection. * Needs to be provided when `value` is bound in and `valuePrimitive` is set to true. */ set dataItem(item) { this._dataItem = item; this.setState(); } get dataItem() { return this._dataItem ? this._dataItem : this.value; } /** * 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 disabled state of the component. To learn how to disable the component in reactive forms, refer to the article on [Forms Support](slug:formssupport_ddt#toc-managing-the-dropdowntree-disabled-state-in-reactive-forms). */ disabled = false; /** * Sets the read-only state of the component. * * @default false */ readonly = false; /** * Specifies the type of the selected value * ([more information and example]({% slug valuebinding_ddt %}#toc-primitive-values)). * If set to `true`, the selected value has to be of a primitive value. */ valuePrimitive = false; /** * Specifies the [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the component. */ set tabindex(value) { const providedTabIndex = Number(value); const defaultTabIndex = 0; this._tabindex = !isNaN(providedTabIndex) ? providedTabIndex : defaultTabIndex; } get tabindex() { return this.disabled ? -1 : 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 = 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('picker', this.fillMode)); if (fillMode !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getFillModeClass('picker', newFillMode)); } this._fillMode = newFillMode; } get fillMode() { return this._fillMode; } /** * A function that is executed for each data item and determines if a specific item is disabled. */ itemDisabled = itemDisabled; /** * A function that is executed for each data item and determines if a specific node is expanded. */ isNodeExpanded; /** * A callback which determines whether a tree node should be rendered as hidden. The utility .k-hidden class is used to hide the nodes. * Useful for custom filtering implementations. */ isNodeVisible = isNodeVisible; /** * Indicates whether the child nodes will be fetched on node expand or will be initially prefetched. * @default true */ loadOnDemand = true; /** * Renders the built-in input element for filtering the DropDownTree. * If set to `true`, the component emits the `filterChange` event, which can be used to [filter the DropDownTree manually]({% slug filtering_ddt %}#toc-manual-filtering). * A built-in filtering implementation is available to use with the [`kendoDropDownTreeHierarchyBinding`]({% slug api_dropdowns_dropdowntreehierarchybindingdirective %}) and [`kendoDropDownTreeFlatBinding`]({% slug api_dropdowns_dropdowntreeflatbindingdirective %}) directives. */ filterable = false; /** * @hidden */ filter = ''; /** * @hidden * * Used by the kendo-label and kendo-floatinglabel to access and associate the focusable element with the provided label via aria-labelledby. */ focusableId = `k-${guid()}`; set isFocused(isFocused) { this.renderer[isFocused ? 'addClass' : 'removeClass'](this.wrapper.nativeElement, 'k-focus'); this._isFocused = isFocused; } get isFocused() { return this._isFocused; } get width() { const wrapperWidth = this.wrapper.nativeElement.offsetWidth; 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'; } /** * Returns the current open state. Returns `true` if the popup or actionSheet is open. */ get isOpen() { return isTruthy(isPresent(this.popupRef) || this.isActionSheetExpanded); } get clearButtonVisiblity() { if (this.touchEnabled) { return 'visible'; } } get isFilterActive() { return this.filterInput && document.activeElement === this.filterInput.nativeElement; } popupRef; /** * @hidden */ selectedKeys = []; /** * @hidden */ selectBy; /** * @hidden */ text; /** * @hidden */ onFilterChange(text) { if (this.filterable) { this.filterChange.emit(text); } } /** * 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 */ isActionSheetExpanded = false; /** * @hidden */ get isAdaptive() { return this.isAdaptiveModeEnabled && this.windowSize !== 'large'; } /** * @hidden * * Alias for `isNodeExpanded`. Used for compatibility with the `ExpandableComponent` interface. * Required for the expand-directive. */ set isExpanded(callback) { this.isNodeExpanded = callback; } get isExpanded() { return this.isNodeExpanded; } /** * @hidden * * Alias for `nodeExpand`. Used for compatibility with the `ExpandableComponent` interface. * Required for the expand-directive. */ get expand() { return this.nodeExpand; } /** * @hidden * * Alias for `nodeCollapse`. Used for compatibility with the `ExpandableComponent` interface. * Required for the expand-directive. */ get collapse() { return this.nodeCollapse; } /** * @hidden * * Alias for `data`. Used for compatibility with the `DataBoundComponent` interface. * Required for the data-binding directives. */ set nodes(nodes) { this.data = nodes; } get nodes() { return this.data; } /** * @hidden * * Alias for `fetchChildren`. Used for compatibility with the `DataBoundComponent` interface. * Required for the data-binding directives */ set children(callback) { this.fetchChildren = callback; } get children() { return this.fetchChildren; } /** * @hidden * * Alias for `isNodeVisible`. Used for compatibility with the `DataBoundComponent` interface. * The `DataBoundComponent` interface is used in the data-binding directives. */ set isVisible(callback) { this.isNodeVisible = callback; } get isVisible() { return this.isNodeVisible; } /** * @hidden */ filterStateChange = new EventEmitter(); /** * @hidden */ allNodesHidden = false; /** * @hidden * * Used to associate the value label with the wrapper via aria-describedby. */ valueLabelId = `k-${guid()}`; /** * @hidden */ get formControl() { const ngControl = this.injector.get(NgControl, null); return ngControl?.control || null; } treeViewId = `k-${guid()}`; _nodes; _value; _popupSettings = DEFAULT_POPUP_SETTINGS; _tabindex = 0; _isFocused = false; _dataItem; _treeview; _size = 'medium'; _rounded = 'medium'; _fillMode = 'solid'; _searchableNodes = []; _typedValue = ''; printableCharacters = new Subject(); subs = new Subscription(); touchstartDisposeHandler; // Keep an instance of the last focused node for when the popup close is prevented // in order to be able to properly restore the focus lastNodeOnFocus; constructor(injector, wrapper, popupService, navigationService, renderer, _zone, cdr, localization, adaptiveService) { this.injector = injector; this.wrapper = wrapper; this.popupService = popupService; this.navigationService = navigationService; this.renderer = renderer; this._zone = _zone; this.cdr = cdr; this.localization = localization; this.adaptiveService = adaptiveService; this.direction = localization.rtl ? 'rtl' : 'ltr'; this.subscribeEvents(); this.subscribeTouchEvents(); this.subscribeFocusEvents(); } ngOnInit() { this.subs.add(this.printableCharacters.pipe(tap((char) => { this._typedValue += char; const itemToFocus = this._searchableNodes.find((node) => { return node.text.toLowerCase().indexOf(this._typedValue) === 0; }); this.treeview.focus(itemToFocus?.index); }), debounceTime(1000)).subscribe(() => { this._typedValue = ''; })); this.renderer.removeAttribute(this.wrapper.nativeElement, 'tabindex'); this.assignAriaDescribedBy(); this.subs.add(this.localization .changes.subscribe(({ rtl }) => { this.direction = rtl ? 'rtl' : 'ltr'; this.cdr.markForCheck(); })); this.setComponentClasses(); } /** * @hidden */ ngOnDestroy() { this.destroyPopup(); this.unsubscribeEvents(); } /** * @hidden */ ngOnChanges(changes) { if (anyChanged(['textField', 'valueField', 'valuePrimitive'], changes, false)) { this.setState(); } if (isChanged('value', changes, false)) { if (changes['value'] && !changes['dataItem'] && !this.valuePrimitive) { // Update the dataItem if the value is updated programmatically (non-primitive values only) this.dataItem = this.value; } } } /** * @hidden */ ngAfterContentChecked() { this.verifySettings(); if (this.data?.length > 0 && this.popupRef) { this.allNodesHidden = this.areNodesHidden(this.data); } } ngAfterViewInit() { this.windowSize = this.adaptiveService.size; this.subs.add(this.renderer.listen(this.wrapper.nativeElement, 'click', this.handleClick.bind(this))); this.subs.add(this.renderer.listen(this.wrapper.nativeElement, 'keydown', this.handleKeydown.bind(this))); 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 */ 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; } } /** * @hidden * * Used by the kendo-floatinglabel component to determine if the floating label * should be rendered inside the input when the component is not focused. */ isEmpty() { return !this.text && !this.placeholder; } /** * @hidden */ togglePopup(open) { const isDisabled = this.disabled || this.readonly; const sameState = this.isOpen === open; if (isDisabled || sameState) { return; } const togglePrevented = this.triggerPopupEvents(open); if (!togglePrevented) { if (open) { this.createPopup(); } else { this.destroyPopup(); } } } /** * @hidden */ handleFocus(event) { if (event.target !== this.wrapper.nativeElement) { return; } event.stopImmediatePropagation(); if (!this.isFocused) { this.isFocused = true; if (hasObservers(this.onFocus)) { this._zone.run(() => { this.onFocus.emit(); }); } } } /** * @hidden */ handleBlur(e) { if (!this.isActionSheetExpanded) { const relatedTarget = e && e.relatedTarget; if (this.wrapper.nativeElement.contains(relatedTarget) || (this.isOpen && this.popupRef.popupElement.contains(relatedTarget))) { return; } this.isFocused = false; this.togglePopup(false); if (hasObservers(this.onBlur) || isUntouched(this.wrapper.nativeElement) || this.formControl?.updateOn === 'blur') { this._zone.run(() => { this.onBlur.emit(); this.onTouchedCallback(); }); } } } /** * @hidden */ handleKeydown(event, input) { if (this.disabled || this.readonly) { return; } if (event.keyCode === Keys.Tab && this.isActionSheetExpanded) { this.togglePopup(false); return; } if (!this.isFilterActive && isLetter(event.key) && !this.actionSheetSearchBar?.onFocus) { this.printableCharacters.next(event.key); } const eventData = event; this.navigationService.process({ originalEvent: eventData, openOnSpace: !this.isOpen, closeOnSpace: this.isOpen && !input && !(event.target instanceof HTMLInputElement) }); } /** * Focuses a specific item of the DropDownTree based on a provided index in the format of `1_1`. * The targeted item should be expanded in order for it to be focused. * If null or invalid index is provided the focus will be set on the first item. */ focusItemAt(index) { if (this.treeview) { const lookup = this.treeview.itemLookup(index); const isItemDisabled = !isPresent(lookup) || this.treeview.isDisabled(lookup.item.dataItem, lookup.item.index); if (!isItemDisabled) { this.treeview.focus(index); } } } /** * Focuses the DropDownTree. */ focus() { if (!this.disabled) { this.wrapper.nativeElement.focus(); } } /** * Blurs the DropDownTree. */ blur() { if (!this.disabled) { this.wrapper.nativeElement.blur(); } } /** * Resets the value of the DropDownTree. * If you use the `reset` method to clear the value of the component, * the model will not update automatically and the `valueChange` event will not be fired. */ reset() { this.value = undefined; this.dataItem = undefined; } /** * Toggles the visibility of the popup or actionSheet. * ([see example]({% slug openstate_ddt %})). * 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) { // The Promise is required to open the popup on load. // Otherwise, the "ViewContainerRef not found..." error will be thrown. Promise.resolve(null).then(() => { const shouldOpen = isPresent(open) ? open : !isPresent(this.popupRef); this.destroyPopup(); if (shouldOpen) { this.createPopup(); } }); } /** * @hidden */ get popupContainerClasses() { const containerClasses = ['k-dropdowntree-popup']; if (this.popupSettings.popupClass) { containerClasses.push(this.popupSettings.popupClass); } return containerClasses; } /** * @hidden */ onSelectionChange({ dataItem, index }) { this.valueDepth = index.split('_').length - 1; const valueField = this.getField(this.valueField, dataItem); const newValue = this.valuePrimitive ? getter(valueField)(dataItem) : dataItem; const shouldUpdateValue = newValue !== this.value; if (shouldUpdateValue) { this.value = newValue; this.dataItem = dataItem; this.emitValueChange(this.value); } this.togglePopup(false); this.focus(); } /** * @hidden */ messageFor(key) { return this.localization.get(key); } /** * @hidden */ clearValue(event) { event.stopImmediatePropagation(); this.focus(); this.value = undefined; this.dataItem = undefined; this.clearState(); this.valueChange.emit(undefined); this.emitValueChange(); } get appendTo() { const { appendTo } = this.popupSettings; if (!appendTo || appendTo === 'root') { return undefined; } return appendTo === 'component' ? this.container : appendTo; } /** * @hidden */ preventEventDefault(event) { event.preventDefault(); } /** * @hidden */ writeValue(value) { // If the user resets the value by providing null/undefined we need to reset the `dataItem` // Because upon initialization of the component the `writeValue` is being called twice - // first time with `null` value regardless of sync/async value - an extra check is added to // distinguish between client reset and initial phantom 'null' value if (!isPresent(value) && isPresent(this.value)) { this.dataItem = null; } this.value = value === null ? undefined : value; // Update the dataItem if the value is updated programmatically via a form control (non-primitive values only) if (isPresent(this.value) && !this.valuePrimitive) { this.dataItem = this.value; } } /** * @hidden */ registerOnChange(fn) { this.onChangeCallback = fn; } /** * @hidden */ registerOnTouched(fn) { this.onTouchedCallback = fn; } /** * @hidden */ setDisabledState(isDisabled) { this.disabled = isDisabled; this.cdr.markForCheck(); } /** * @hidden */ handleFilterInputChange(input) { const value = typeof input === 'string' ? input : input.value; this.filter = value; this.filterChange.next(value); this.allNodesHidden = this.areNodesHidden(this.nodes); this._zone.onStable.pipe(take(1)).subscribe(() => { if (this.data.length === 0 || this.allNodesHidden || this.filter === '') { this.filterInput?.nativeElement.focus(); } }); } /** * @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) : ''}`; } onTouchedCallback = noop; onChangeCallback = noop; verifySettings() { if (!isDevMode()) { return; } if (this.valuePrimitive === true && isPresent(this.value) && typeof this.value === "object") { throw new Error(DropDownTreeMessages.primitive); } if (this.valuePrimitive === true && isPresent(this.value) && typeof this.dataItem !== "object") { throw new Error(DropDownTreeMessages.dataItem); } if (this.valuePrimitive === false && isPresent(this.value) && typeof this.value !== "object") { throw new Error(DropDownTreeMessages.object); } if (!isPresent(this.valueField) || !isPresent(this.textField)) { throw new Error(DropDownTreeMessages.textAndValue); } if ((isArray(this.valueField) || isArray(this.textField)) && isPresent(this.value) && !isPresent(this.valueDepth)) { throw new Error(DropDownTreeMessages.valueDepth); } } emitValueChange(value) { this.onChangeCallback(value); this.valueChange.emit(value); } getText(textField, dataItem) { if (isPresent(dataItem) && isPresent(textField)) { const field = this.getField(textField, dataItem); return getter(field)(dataItem); } return null; } /** * @hidden */ onChildrenLoaded() { setTimeout(() => { if (this.popupRef) { this._searchableNodes = getSearchableItems(this.treeViewId, this.popupRef.popupElement); } if (this.isActionSheetExpanded) { this._searchableNodes = getSearchableItems(this.treeViewId, this.actionSheet.element.nativeElement); } }); } /** * @hidden */ onExpand() { this._searchableNodes = getSearchableItems(this.treeViewId, this.actionSheet.element.nativeElement); this.isActionSheetExpanded = true; } /** * @hidden */ onCollapse() { this.isActionSheetExpanded = false; } /** * @hidden * * Determines the `valueField` and `textField` for a specific level in the data set * @param field - the field value (string | string[]) * @param value - current value */ getField(field, value) { const fieldsCount = field.length - 1; if (typeof field === 'string') { // If the `valueField` | `textField` is the same for all levels return field; } else if (isPresent(this.valueDepth)) { // When `valueDepth` can be defined from the index on selectionChange or provided by the user return fieldsCount < this.valueDepth ? field[fieldsCount] : field[this.valueDepth]; } else if (value && typeof value === 'object') { // Fallback: Look to find a match of each field in the current data item // Side effect may occur if all of the listed fields are present in the data item return field.find(item => item in value); } } areNodesHidden(nodes) { return nodes.every((node, index) => !this.isVisible(node, String(index))); } triggerPopupEvents(open) { const eventArgs = new PreventableEvent(); if (open) { this.open.emit(eventArgs); } else { this.close.emit(eventArgs); } return eventArgs.isDefaultPrevented(); } createPopup() { this.windowSize = this.adaptiveService.size; if (this.isAdaptive) { this.openActionSheet(); this.cdr.detectChanges(); 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, appendTo: this.appendTo, anchorAlign: anchorPosition, animate: this.popupSettings.animate, content: this.popupTemplate, popupAlign: popupPosition, positionMode: appendToComponent ? 'fixed' : 'absolute', popupClass: this.popupContainerClasses }); const popupWrapper = this.popupRef.popupElement; const { min, max } = this.width; this.renderer.setStyle(popupWrapper, 'minWidth', min); this.renderer.setStyle(popupWrapper, 'width', max); this.renderer.setStyle(popupWrapper, 'height', this.height); this.renderer.setAttribute(popupWrapper, 'dir', this.direction); if (!this.appendTo) { this.renderer.setAttribute(popupWrapper, 'role', 'region'); this.renderer.setAttribute(popupWrapper, 'aria-label', this.messageFor('popupLabel')); } this.popupRef.popupOpen.subscribe(() => { this.allNodesHidden = this.areNodesHidden(this.data); this.popupRef.popupElement.addEventListener('click', (event) => stopPropagation(event)); this.cdr.detectChanges(); this.opened.emit(); this._searchableNodes = getSearchableItems(this.treeViewId, this.popupRef.popupElement); }); this.popupRef.popupClose.subscribe(() => { this.closed.emit(); }); } destroyPopup() { if (this.isActionSheetExpanded) { this.closeActionSheet(); } if (this.popupRef) { this.popupRef.popupElement.removeEventListener('click', (event) => stopPropagation(event)); this.popupRef.close(); this.popupRef = null; if (this.filter !== "") { this.filter = ""; this.allNodesHidden = false; if (hasObservers(this.filterChange)) { this._zone.run(() => { this.filterChange.emit(""); }); } } } } handleClick(e) { // The check is needed otherwise when appended to the component, the popup reopens on click // https://github.com/telerik/kendo-angular/issues/3738 this.windowSize = this.adaptiveService.size; if (!this.isActionSheetExpanded) { if ((this.popupRef && !this.popupRef.popupElement.contains(e.target)) || (!this.popupRef && !e.target.className.includes('k-treeview-leaf'))) { this.togglePopup(!this.isOpen); } } } handleEscape() { this.togglePopup(false); this.focus(); } setState() { if (isPresent(this.value) && isPresent(this.dataItem) && isPresent(this.valueField)) { this.text = this.getText(this.textField, this.dataItem); const valueField = this.getField(this.valueField, this.dataItem); this.selectBy = valueField; this.selectedKeys = [getter(valueField)(this.dataItem)]; } else { this.clearState(); } this.cdr.markForCheck(); } clearState() { this.text = undefined; this.selectedKeys = []; } subscribeEvents() { [ this.navigationService.open.subscribe((event) => { event.originalEvent.preventDefault(); this.togglePopup(true); }), this.navigationService.close.subscribe((event) => { event.originalEvent.preventDefault(); this.togglePopup(false); this.focus(); }), this.navigationService.enter .pipe(tap((event) => event.originalEvent.preventDefault())) .subscribe((e) => { // The check is needed otherwise when appended to the component, the popup reopens on click // https://github.com/telerik/kendo-angular/issues/3738 if (e.originalEvent.target === this.wrapper.nativeElement) { this.togglePopup(true); } if (!this.isOpen) { this.focus(); } }), this.navigationService.esc .subscribe(() => this.handleEscape()), this.navigationService.tab.subscribe(() => { this.focus(); if (this.isOpen) { this.treeview.blur(); this.removeTreeViewFromTabOrder(); } }), this.navigationService.down.subscribe((event) => { if (!this.treeview) { return; } event.originalEvent.preventDefault(); if (!this.treeview.isActive) { this.treeview.focus(); } }), this.navigationService.up.subscribe((event) => { if (!this.treeview) { return; } event.originalEvent.preventDefault(); if (this.filterable && this.treeview['navigationService']['activeIndex'] === '0') { if (this.isActionSheetExpanded) { this.actionSheetSearchBar.focus(); } else { this.filterInput.nativeElement.focus(); } } }) ].forEach(sub => this.subs.add(sub)); } subscribeTouchEvents() { if (!isDocumentAvailable() || !this.touchEnabled) { return; } this._zone.runOutsideAngular(() => // Roll up DropDownTree 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.isOpen) { this.treeview.blur(); } this.blur(); }); } })); } subscribeFocusEvents() { if (isDocumentAvailable()) { this.handleFocus = this.handleFocus.bind(this); this.handleDocumentBlur = this.handleDocumentBlur.bind(this); this._zone.runOutsideAngular(() => { const useCapture = true; document.addEventListener('focus', this.handleFocus, useCapture); document.addEventListener('blur', this.handleDocumentBlur, useCapture); }); } } unSubscribeFocusEvents() { if (isDocumentAvailable()) { const useCapture = true; document.removeEventListener('focus', this.handleFocus, useCapture); document.removeEventListener('blur', this.handleDocumentBlur, useCapture); } } unsubscribeEvents() { this.subs.unsubscribe(); this.unSubscribeFocusEvents(); if (this.touchstartDisposeHandler) { this.touchstartDisposeHandler(); } } handleDocumentBlur(event) { if (event.target !== this.wrapper.nativeElement) { return; } event.stopImmediatePropagation(); this.handleBlur(event); } assignAriaDescribedBy() { const currentValue = this.wrapper.nativeElement.getAttribute('aria-describedby') || ''; // add to the current value - don't replace it (the aria-describedby is used by the FormField component as well) const newValue = `${this.valueLabelId} ${currentValue.trim()}`.trim(); this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-describedby', newValue); } setComponentClasses() { if (this.size !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getSizeClass('picker', this.size)); } if (this.rounded !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getRoundedClass(this.rounded)); } if (this.fillMode !== 'none') { this.renderer.addClass(this.wrapper.nativeElement, getFillModeClass('picker', this.fillMode)); } } /** * Remove the `TreeView` from the tab order, otherwise a focus loop between the page elements will occur * and the user will not be able to tab to the rest of the browser elements */ removeTreeViewFromTabOrder() { const nodes = this.treeview.element.nativeElement.querySelectorAll('li'); nodes.forEach(item => { if (item.getAttribute('tabindex') === '0') { this.lastNodeOnFocus = item; this.lastNodeOnFocus.setAttribute('tabindex', '-1'); } }); } closeActionSheet() { this.actionSheet.toggle(false); if (this.filterable) { this.actionSheetSearchBar.value = ''; this.filterChange.emit(''); } this.wrapper.nativeElement.focus(); this.closed.emit(); } openActionSheet() { this.windowSize = this.adaptiveService.size; this.isActionSheetExpanded = true; this.actionSheet.toggle(true); this.title = setActionSheetTitle(this.wrapper, this.title); this.cdr.detectChanges(); updateActionSheetAdaptiveAppearance(this.actionSheet, this.windowSize, this.renderer); this.cdr.detectChanges(); this.opened.emit(); this.removeTreeViewFromTabOrder(); this.filterable && this.actionSheetSearchBar.focus(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: DropDownTreeComponent, deps: [{ token: i0.Injector }, { token: i0.ElementRef }, { token: i1.PopupService }, { token: i2.NavigationService }, { token: i0.Renderer2 }, { token: i0.NgZone }, { token: i0.ChangeDetectorRef }, { token: i3.LocalizationService }, { token: i4.AdaptiveService }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: DropDownTreeComponent, isStandalone: true, selector: "kendo-dropdowntree", inputs: { icon: "icon", svgIcon: "svgIcon", loading: "loading", clearButton: "clearButton", data: "data", value: "value", textField: "textField", valueField: "valueField", valueDepth: "valueDepth", hasChildren: "hasChildren", fetchChildren: "fetchChildren", placeholder: "placeholder", popupSettings: "popupSettings", dataItem: "dataItem", listHeight: "listHeight", disabled: "disabled", readonly: "readonly", valuePrimitive: "valuePrimitive", tabindex: "tabindex", size: "size", rounded: "rounded", fillMode: "fillMode", itemDisabled: "itemDisabled", isNodeExpanded: "isNodeExpanded", isNodeVisible: "isNodeVisible", loadOnDemand: "loadOnDemand", filterable: "filterable", filter: "filter", focusableId: "focusableId", adaptiveMode: "adaptiveMode", title: "title", subtitle: "subtitle" }, outputs: { open: "open", opened: "opened", close: "close", closed: "closed", nodeExpand: "nodeExpand", nodeCollapse: "nodeCollapse", onFocus: "focus", onBlur: "blur", valueChange: "valueChange", filterChange: "filterChange" }, host: { properties: { "class.k-dropdowntree": "this.hostClasses", "class.k-picker": "this.hostClasses", "attr.readonly": "this.isReadonly", "attr.aria-invalid": "this.hostAriaInvalid", "class.k-disabled": "this.isDisabled", "attr.aria-disabled": "this.isDisabled", "class.k-loading": "this.isLoading", "attr.aria-busy": "this.isBusy", "attr.aria-controls": "this.hostAriaControls", "attr.id": "this.id", "attr.dir": "this.direction", "attr.tabindex": "this.hostTabIndex", "attr.role": "this.role", "attr.aria-haspopup": "this.ariaHasPopup", "attr.aria-expanded": "this.isAriaExpanded", "attr.aria-autocomplete": "this.hostAriaAutocomplete", "class.k-readonly": "this.readonly" } }, providers: [ DataService, SelectionService, NavigationService, DisabledItemsService, LocalizationService, { provide: L10N_PREFIX, useValue: 'kendo.dropdowntree' }, { multi: true, provide: NG_VALUE_ACCESSOR,