@progress/kendo-angular-dropdowns
Version:
A wide variety of native Angular dropdown components including AutoComplete, ComboBox, DropDownList, DropDownTree, MultiColumnComboBox, MultiSelect, and MultiSelectTree
1,355 lines • 96.8 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 { ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, forwardRef, HostBinding, Injector, Input, isDevMode, NgZone, Output, Renderer2, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core';
import { NgControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { anyChanged, guid, hasObservers, Keys, KendoInput, isDocumentAvailable, EventsOutsideAngularDirective, ResizeSensorComponent, TemplateContextDirective } from '@progress/kendo-angular-common';
import { AdaptiveService } from '@progress/kendo-angular-utils';
import { L10N_PREFIX, LocalizationService } from '@progress/kendo-angular-l10n';
import { NavigationService } from '../common/navigation/navigation.service';
import { PopupService } from '@progress/kendo-angular-popup';
import { DataBoundComponent, ExpandableComponent, TreeViewComponent } from '@progress/kendo-angular-treeview';
import { DataService } from '../common/data.service';
import { DisabledItemsService } from '../common/disabled-items/disabled-items.service';
import { SelectionService } from '../common/selection/selection.service';
import { PreventableEvent } from '../common/models/preventable-event';
import { NavigationAction } from '../common/navigation/navigation-action';
import { RemoveTagEvent } from '../common/models/remove-tag-event';
import { MultiSelectTreeMessages } from '../common/constants/error-messages';
import { animationDuration, fetchDescendentNodes, getFillModeClass, getRoundedClass, getSearchableItems, getSizeClass, hasProps, isArray, isLetter, isObject, isObjectArray, isPresent, isTruthy, isUntouched, noop, parseNumber, setActionSheetTitle, updateActionSheetAdaptiveAppearance, valueFrom } from '../common/util';
import { HeaderTemplateDirective } from '../common/templates/header-template.directive';
import { FooterTemplateDirective } from '../common/templates/footer-template.directive';
import { NodeTemplateDirective } from './templates/node-template.directive';
import { NoDataTemplateDirective } from '../common/templates/no-data-template.directive';
import { TagTemplateDirective } from '../common/templates/tag-template.directive';
import { GroupTagTemplateDirective } from '../common/templates/group-tag-template.directive';
import { merge, of, Subject, Subscription } from 'rxjs';
import { debounceTime, filter, tap } from 'rxjs/operators';
import { buildTreeItem, MultiSelectTreeLookupService, nodeIndex } from './lookup/lookup.service';
import { searchIcon, xIcon } from '@progress/kendo-svg-icons';
import { ResponsiveRendererComponent } from '../common/action-sheet.component';
import { CheckDirective } from './checked-state/check.directive';
import { CheckAllDirective } from './checked-state/check-all.directive';
import { FilterInputDirective } from '../common/filter-input.directive';
import { NgIf, NgTemplateOutlet, NgClass } from '@angular/common';
import { TagListComponent } from '../common/taglist.component';
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-popup";
import * as i2 from "../common/navigation/navigation.service";
import * as i3 from "@progress/kendo-angular-l10n";
import * as i4 from "./lookup/lookup.service";
import * as i5 from "@progress/kendo-angular-utils";
const DEFAULT_POPUP_SETTINGS = { animate: true };
const DEFAULT_CHECKABLE_SETTINGS = { checkChildren: true, checkOnClick: 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';
/**
* Represents the [Kendo UI MultiSelectTree component for Angular]({% slug overview_multiselecttree %}).
*/
export class MultiSelectTreeComponent {
injector;
wrapper;
popupService;
renderer;
navigationService;
_zone;
localization;
cdr;
lookup;
adaptiveService;
/**
* @hidden
*/
touchEnabled = touchEnabled;
/**
* @hidden
*/
animationDuration = animationDuration;
/**
* @hidden
*/
searchIcon = searchIcon;
/**
* @hidden
*/
xIcon = xIcon;
hostClasses = true;
get isDisabled() {
return this.disabled || null;
}
treeViewId = `k-${guid()}`;
get hostAriaAutocomplete() {
return this.filterable ? 'list' : null;
}
get isLoading() {
return this.loading;
}
get hostAriaInvalid() {
return this.formControl?.invalid ? true : null;
}
get isBusy() {
return this.loading ? 'true' : null;
}
get id() {
return this.focusableId;
}
direction;
get hostTabIndex() {
return this.tabindex;
}
role = 'combobox';
ariaHasPopup = 'tree';
get isReadonly() {
return this.readonly ? '' : null;
}
get ariaDescribedBy() {
return this.tagListId;
}
get ariaActiveDescendant() {
return this.focusedTagId;
}
/**
* @hidden
*/
get formControl() {
const ngControl = this.injector.get(NgControl, null);
return ngControl?.control || null;
}
/**
* @hidden
*/
onFilterChange(text) {
if (this.filterable) {
this.filterChange.emit(text);
}
}
/**
* @hidden
*/
onExpand() {
this._searchableNodes = getSearchableItems(this.treeViewId, this.actionSheet.element.nativeElement);
this.isActionSheetExpanded = true;
}
/**
* @hidden
*/
onCollapse() {
this.isActionSheetExpanded = false;
}
/**
* Enables or disables the adaptive mode. By default the adaptive rendering is disabled.
*/
adaptiveMode = 'none';
/**
* Sets the title of the ActionSheet that is rendered instead of the Popup when using small screen devices.
* By default the ActionSheet title uses the text provided for the label of the AutoComplete.
*/
title = '';
/**
* Sets the subtitle of the ActionSheet that is rendered instead of the Popup when using small screen devices.
* By default the ActionSheet subtitle uses the text provided for the `placeholder` of the AutoComplete.
*/
set subtitle(_subtitle) {
this._subtitle = _subtitle;
}
get subtitle() {
return this._subtitle || this.placeholder;
}
/**
* @hidden
*/
get isAdaptiveModeEnabled() {
return this.adaptiveMode === 'auto';
}
/**
* @hidden
*/
windowSize = 'large';
/**
* @hidden
*/
isActionSheetExpanded = false;
/**
* @hidden
*/
handleKeydown(event, input) {
if (event.target === this.filterInput?.nativeElement &&
(event.keyCode === Keys.ArrowLeft || event.keyCode === Keys.ArrowRight)) {
return;
}
if (input) {
event.stopImmediatePropagation();
}
const deleteTag = this.isWrapperActive && event.keyCode === Keys.Backspace && this.tags.length > 0;
if (deleteTag) {
this.handleBackspace();
return;
}
if (this.disabled || this.readonly) {
return;
}
if (!this.isFilterActive && isLetter(event.key) && !this.actionSheetSearchBar?.onFocus) {
this.printableCharacters.next(event.key);
}
const eventData = event;
const action = this.navigationService.process({
originalEvent: eventData,
openOnSpace: !this.isOpen,
closeOnSpace: false
});
if (action === NavigationAction.Open) {
eventData.preventDefault();
}
if (this.isOpen && action === NavigationAction.Enter) {
const spaceKeyDownEvent = new KeyboardEvent('keydown', { 'key': ' ', 'code': 'Space', 'keyCode': 32, 'which': 32 });
this.treeview?.element.nativeElement.dispatchEvent(spaceKeyDownEvent);
}
}
/**
* @hidden
*/
responsiveRendererComponent;
/**
* @hidden
*/
get actionSheet() {
return this.responsiveRendererComponent?.actionSheet;
}
/**
* @hidden
*/
get actionSheetSearchBar() {
return this.responsiveRendererComponent?.actionSheetSearchBar;
}
/**
* @hidden
*/
get isAdaptive() {
return this.isAdaptiveModeEnabled && this.windowSize !== 'large';
}
headerTemplate;
footerTemplate;
nodeTemplate;
noDataTemplate;
tagTemplate;
groupTagTemplate;
popupTemplate;
container;
set treeview(treeview) {
this._treeview = treeview;
if (treeview) {
// If filtering is enabled, focus the TreeView on mobile devices instead of the filter input
if (this.isFocused && !this.filterable && !this.checkAll || 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
* The Promise is required to properly change the `animate` property when
* the popup is appended to a container and opened upon initialization.
* Otherwise, the "Expression has changed..." type error will be thrown.
*/
Promise.resolve(null).then(() => this.treeview.animate = true);
}
}
get treeview() {
return this._treeview;
}
filterInput;
checkAllInput;
/**
* Specifies the [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the component.
*/
set tabindex(value) {
const providedTabIndex = parseNumber(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('input', this.size));
if (size !== 'none') {
this.renderer.addClass(this.wrapper.nativeElement, getSizeClass('input', newSize));
}
this._size = newSize;
}
get size() {
return this._size;
}
/**
* Sets the border radius of the component.
*
* The possible values are:
* * `small`
* * `medium` (default)
* * `large`
* * `full`
* * `none`
*
*/
set rounded(rounded) {
const newRounded = rounded ? rounded : DEFAULT_ROUNDED;
this.renderer.removeClass(this.wrapper.nativeElement, getRoundedClass(this.rounded));
if (rounded !== 'none') {
this.renderer.addClass(this.wrapper.nativeElement, getRoundedClass(newRounded));
}
this._rounded = newRounded;
}
get rounded() {
return this._rounded;
}
/**
* Sets the fillMode of the component.
*
* The possible values are:
* * `flat`
* * `solid` (default)
* * `outline`
* * `none`
*
*/
set fillMode(fillMode) {
const newFillMode = fillMode ? fillMode : DEFAULT_FILL_MODE;
this.renderer.removeClass(this.wrapper.nativeElement, getFillModeClass('input', this.fillMode));
if (fillMode !== 'none') {
this.renderer.addClass(this.wrapper.nativeElement, getFillModeClass('input', newFillMode));
}
this._fillMode = newFillMode;
}
get fillMode() {
return this._fillMode;
}
/**
* Configures the popup of the MultiSelectTree.
*
* 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.
* - `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({}, DEFAULT_POPUP_SETTINGS, settings);
// `detectChanges` needed, otherwise upon value initialization and `appendTo` property
// an error is thrown => ExpressionChangedAfterItHasBeenCheckedError
this.cdr.detectChanges();
}
get popupSettings() {
return this._popupSettings;
}
/**
* Defines the checkable settings of the MultiSelecTree nodes.
* If no value is provided, the default [`CheckableSettings`]({% slug api_dropdowns_multiselecttreecheckablesettings %}) are applied.
*/
set checkableSettings(settings) {
this._checkableSettings = Object.assign({}, DEFAULT_CHECKABLE_SETTINGS, settings);
}
get checkableSettings() {
return this._checkableSettings;
}
/**
* Sets the data of the MultiSelectTree.
*
* > The data has to be provided in an array-like list with objects.
*/
set data(data) {
this._nodes = data;
this.setState();
if (this.isContentInit) {
// Needed for when the data is loaded later/asynchronously because it would not exist on ngContentInit
this.registerLookupItems(data);
}
}
get data() {
return this._nodes;
}
/**
* Sets the value of the MultiSelectTree.
* 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(value) {
this._value = value ? value : [];
this.setState();
}
get value() {
return this._value;
}
/**
* Keeps the current `dataItems` object in order to resolve selection.
* Needs to be provided when when programmatically setting a `value` and `valuePrimitive` is set to `true`.
*/
set dataItems(items) {
this._dataItems = (items || []).map((dataItem, index) => {
if (hasProps(dataItem, ['dataItem', 'index', 'level'])) {
return dataItem;
}
const level = this.valueDepth[index] || 0;
const key = valueFrom({ dataItem, level }, this.valueField) + '_' + (this.isHeterogeneous ? this.valueDepth[index] : 0);
return {
dataItem,
index: null,
level,
key
};
});
this.setState();
}
get dataItems() {
return this._dataItems || this.value.map((value, index) => {
const level = this.valueDepth[index] || 0;
const key = valueFrom({ dataItem: value, level }, this.valueField) + '_' + (this.isHeterogeneous ? this.valueDepth[index] : 0);
return {
dataItem: value,
index: null,
level,
key
};
});
}
/**
* The fields of the data item that provide the text content of the nodes inside the
* MultiSelectTree ([see example]({% slug databinding_multiselecttree %})). 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
* MultiSelectTree ([see example]({% slug databinding_multiselecttree %})). 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 levels in the data set where the values can be found when `valueField` is an Array.
* The field serves to correctly allocate a data item used when the MultiSelectTree is initialized with a value.
*/
valueDepth = [];
/**
* Sets and gets the loading state of the MultiSelectTree.
*/
loading;
/**
* The hint which is displayed when the component is empty.
*/
placeholder = '';
/**
* 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_multiselecttree#toc-managing-the-multiselecttree-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_multiselecttree %}#toc-primitive-values)).
* If set to `true`, the selected value has to be a primitive one.
*/
valuePrimitive = false;
/**
* Indicates whether the child nodes will be fetched on node expand or will be initially prefetched.
* @default false
*/
loadOnDemand = false;
/**
* @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()}`;
/**
* 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.
* @default true
*/
clearButton = true;
/**
* Renders the built-in input element for filtering the MultiSelectTree.
* If set to `true`, the component emits the `filterChange` event, which can be used to [filter the MultiSelectTree manually]({% slug filtering_multiselecttree %}#toc-manual-filtering).
* A built-in filtering implementation is available to use with the [`kendoMultiSelectTreeHierarchyBinding`]({% slug api_dropdowns_multiselecttreehierarchybindingdirective %}) and [`kendoMultiSelectTreeFlatBinding`]({% slug api_dropdowns_multiselecttreeflatbindingdirective %}) directives.
* @default false
*/
filterable = false;
/**
* If `checkАll` is set to `true` and the checkboxes are enabled, a tri-state checkbox appears above the embedded treeview.
* Clicking the checkbox checks or unchecks all enabled items of the treeview that are loaded.
* @default false
*/
checkAll = false;
/**
* 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;
/**
* 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;
/**
* A function that is executed for each data item and determines if a specific item is disabled.
*/
itemDisabled = itemDisabled;
/**
* A user-defined callback function which receives an array of selected data items and maps them to an array of tags.
*
* @param { Any[] } dataItems - The selected data items from the list.
* @returns { Any[] } - The tags that will be rendered by the component.
*/
tagMapper = (tags) => tags || [];
/**
* Fires each time the user focuses the MultiSelectTree.
*/
onFocus = new EventEmitter();
/**
* Fires each time the MultiSelectTree gets blurred.
*/
onBlur = new EventEmitter();
/**
* Fires each time the popup is about to open
* ([see example]({% slug openstate_multiselecttree %})).
* 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_multiselecttree %})).
* 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 value is changed
* ([see example](slug:events_multiselecttree)).
*/
valueChange = new EventEmitter();
/**
* Fires each time a tag is about to be removed([see examples]({% slug summarytagmode_multiselect %}#toc-notifying-on-removing-group-tags)).
* This event is preventable. If you cancel it, the tag will not be removed.
*/
removeTag = new EventEmitter();
/**
* Fires when the value of the built-in filter input element changes.
*/
filterChange = new EventEmitter();
/**
* @hidden
*/
get focusedTagId() {
if (!isPresent(this.focusedTagIndex) || this.isOpen) {
return null;
}
return this.tagPrefix + '-' + this.focusedTagIndex;
}
set isFocused(isFocused) {
this.renderer[isFocused ? 'addClass' : 'removeClass'](this.wrapper.nativeElement, 'k-focus');
this._isFocused = isFocused;
}
get isFocused() {
return this._isFocused;
}
/**
* Returns the current open state. Returns `true` if the popup or actionSheet is open.
*/
get isOpen() {
return isTruthy(isPresent(this.popupRef) || this.isActionSheetExpanded);
}
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';
}
get appendTo() {
const { appendTo } = this.popupSettings;
if (!appendTo || appendTo === 'root') {
return undefined;
}
return appendTo === 'component' ? this.container : appendTo;
}
/**
* @hidden
*/
get popupContainerClasses() {
const containerClasses = ['k-multiselecttree-popup'];
if (this.popupSettings.popupClass) {
containerClasses.push(this.popupSettings.popupClass);
}
return containerClasses;
}
/**
* @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 `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 `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 `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;
}
get isTagFocused() {
return !this.isOpen && this.focusedTagIndex !== undefined;
}
get isTreeViewActive() {
return this.treeview && this.treeview.isActive;
}
get isWrapperActive() {
return document.activeElement === this.wrapper.nativeElement;
}
get isFilterActive() {
return this.filterInput && document.activeElement === this.filterInput.nativeElement;
}
get isCheckAllActive() {
return this.checkAllInput && document.activeElement === this.checkAllInput.nativeElement;
}
/**
* @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
*/
filterStateChange = new EventEmitter();
/**
* @hidden
*/
filter = '';
/**
* @hidden
*/
checkedItems = [];
/**
* @hidden
* The flag is needed in order to determine how to construct the items map keys.
* If `true`, then the key consists of the item's value and level (depth),
* else the key consists of the item's value and 0 (no leveling required)
*/
isHeterogeneous;
/**
* @hidden
*/
showAfter = 0;
/**
* @hidden
*/
allNodesHidden = false;
tagListId = `k-${guid()}`;
tagPrefix = "tag-" + guid();
popupRef;
tags;
focusedTagIndex = undefined;
disabledIndices;
_subtitle;
_nodes;
_value = [];
_tabindex = 0;
_popupSettings = DEFAULT_POPUP_SETTINGS;
_checkableSettings = DEFAULT_CHECKABLE_SETTINGS;
_isFocused = false;
_treeview;
_dataItems;
_tempValue;
_initiallyCheckedItems = [];
_size = 'medium';
_rounded = 'medium';
_fillMode = 'solid';
_searchableNodes = [];
_typedValue = '';
printableCharacters = new Subject();
subs = new Subscription();
// 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;
// Used as check to avoid unnecessary 'registerLookupItems()' calls upon initialization
isContentInit;
constructor(injector, wrapper, popupService, renderer, navigationService, _zone, localization, cdr, lookup, adaptiveService) {
this.injector = injector;
this.wrapper = wrapper;
this.popupService = popupService;
this.renderer = renderer;
this.navigationService = navigationService;
this._zone = _zone;
this.localization = localization;
this.cdr = cdr;
this.lookup = lookup;
this.adaptiveService = adaptiveService;
this.direction = localization.rtl ? 'rtl' : 'ltr';
this.subscribeEvents();
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.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', String(this.isOpen));
this.subs.add(this.localization
.changes.subscribe(({ rtl }) => {
this.direction = rtl ? 'rtl' : 'ltr';
this.cdr.markForCheck();
}));
this.setComponentClasses();
this._initiallyCheckedItems = [...this.checkedItems];
}
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
*/
ngOnDestroy() {
this.destroyPopup();
this.unsubscribeEvents();
}
/**
* @hidden
*/
ngOnChanges(changes) {
if (anyChanged(['textField', 'valueField', 'valuePrimitive'], changes, false)) {
this.isHeterogeneous = this.valueField && isArray(this.valueField);
this.setState();
}
if (anyChanged(['valueDepth', 'value', 'dataItems'], changes, false)) {
if (changes['value'] && !changes['dataItems'] && !this.valuePrimitive) {
// Update the dataItems if the value is updated programmatically (non-primitive values only)
// In the primitive case, the client should update the dataItems as well
this.dataItems = this.value;
}
else {
// Re-map the dataItems because `valueDepth` is not yet available when the check directive parses the items
this.dataItems = this.dataItems.map((item, index) => ({
...item,
key: valueFrom({ dataItem: item.dataItem, index: null, level: this.valueDepth[index] || 0 }, this.valueField) + '_' + (this.isHeterogeneous ? this.valueDepth[index] : 0),
level: this.valueDepth[index] || 0
}));
}
}
if (anyChanged(['data', 'children', 'hasChildren', 'loadOnDemand', 'valueField'], changes, true) && !this.loadOnDemand) {
this.lookup.reset();
this.registerLookupItems(this.data);
}
}
/**
* @hidden
*/
ngAfterContentChecked() {
this.verifySettings();
if (this.data?.length > 0 && this.popupRef) {
this.allNodesHidden = this.areNodesHidden(this.data);
}
}
/**
* @hidden
*/
applyValue() {
this.value = this._tempValue;
this._initiallyCheckedItems = [...this.checkedItems];
this.emitValueChange(this.value);
this.setTags();
this.toggle(false);
}
/**
* @hidden
*/
cancelValue() {
this.checkedItems = [...this._initiallyCheckedItems];
this.togglePopup(false);
}
ngAfterContentInit() {
this.isContentInit = true;
// Still need to keep the call of 'registerLookupItems()' from ngAfterContentInit in the cases when the data is passed initially
// The call is execute here because we have to make sure it happens after all input properties are loaded (not the case in the data setter initially)
this.registerLookupItems(this.data);
}
/**
* @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.placeholder && (!isPresent(this.value) || this.value.length === 0);
}
/**
* Focuses the MultiSelectTree.
*/
focus() {
if (!this.disabled) {
this.wrapper.nativeElement.focus();
}
}
/**
* Blurs the MultiSelectTree.
*/
blur() {
if (!this.disabled) {
this.wrapper.nativeElement.blur();
}
}
/**
* Focuses a specific item of the MultiSelectTree 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);
}
}
}
/**
* Resets the value of the MultiSelectTree.
* 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 = [];
this.dataItems = [];
this.valueDepth = [];
}
/**
* Toggles the visibility of the popup or actionSheet
* ([see example]({% slug openstate_multiselecttree %})).
* 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 "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
*/
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();
});
}
// Re-focus the treeview if `close` is prevented
if (this.isOpen && this.treeview) {
if (this.lastNodeOnFocus) {
this.lastNodeOnFocus.setAttribute('tabindex', '0');
}
this.treeview.focus();
}
}
}
/**
* @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);
//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();
if (hasObservers(this.onBlur) ||
isUntouched(this.wrapper.nativeElement) ||
this.formControl?.updateOn === 'blur') {
this._zone.run(() => {
this.onBlur.emit();
this.onTouchedCallback();
});
}
}
}
/**
* @hidden
*/
handleNodeClick(node) {
if (!this.isFocused) {
// Re-focus the MultiSelectTree when popup close is prevented and a node is clicked
// On click the focus should be on the clicked element which is why we need to update the lastNodeOnFocus
const parent = node.originalEvent.target.parentElement.parentElement;
this.lastNodeOnFocus = parent;
this.focus();
}
}
/**
* @hidden
*/
togglePopup(open) {
const isDisabled = this.disabled || this.readonly;
const sameState = this.isOpen === open;
this._zone.run(() => {
this.focusedTagIndex = undefined;
});
if (isDisabled || sameState) {
return;
}
const togglePrevented = this.triggerPopupEvents(open);
if (!togglePrevented) {
if (open) {
this.createPopup();
}
else {
this.destroyPopup();
}
}
else {
this.removeTreeViewFromTabOrder();
}
}
/**
* @hidden
*/
messageFor(key) {
return this.localization.get(key);
}
lastAction = 'check';
/**
* @hidden
*/
handleCheckedItemsChange(items) {
this.valueDepth = items.map(item => item.level);
this.lastAction = items.length > this.dataItems.length ? 'check' : 'uncheck';
this.dataItems = items.slice();
this.updateValue(this.dataItems);
}
/**
* @hidden
*/
handleRemoveTag({ tag, index }) {
if (this.disabled || this.readonly) {
return;
}
const eventArgs = new RemoveTagEvent(tag);
this.removeTag.emit(eventArgs);
if (eventArgs.isDefaultPrevented()) {
return;
}
// Remove tags based on their position index
if (tag instanceof Array) {
// Remove group tag
this.dataItems = this.dataItems.filter((_item, i) => i < this.showAfter || this.disabledIndices.has(i));
this.valueDepth = this.valueDepth.filter((_item, i) => i < this.showAfter || this.disabledIndices.has(i));
}
else if (this.loadOnDemand) {
// Remove single tag when the child items are fetched on demand
this.dataItems = this.dataItems.filter((_item, i) => i !== index || this.disabledIndices.has(i));
this.valueDepth = this.valueDepth.filter((_item, i) => i !== index || this.disabledIndices.has(i));
}
else {
// Remove single tag when the child items are pre-fetched
const dataItem = this.dataItems.find(item => item.tagPositionIndex === index);
const itemKey = dataItem.key;
const lookup = this.lookup.itemLookup(itemKey);
const pendingCheck = [lookup.item];
if (this.checkableSettings.checkChildren) {
fetchDescendentNodes(lookup)
.forEach(lookup => pendingCheck.push(lookup.item));
pendingCheck.push(...this.removeParents(lookup.parent));
}
const keysToRemove = pendingCheck.map(item => item.key);
// Holds the position indexes of the items to be removed
const valueDepthIndices = [];
this.dataItems = this.dataItems.filter((_item, i) => {
const shouldStay = !keysToRemove.includes(_item.key) || this.disabledIndices.has(i);
if (!shouldStay) {
// We need to know the index position of the data item to be able to update the valueDepth array accordignly
// as each data item's position is corresponding to the same position in valueDepth
valueDepthIndices.push(i);
}
return shouldStay;
});
this.valueDepth = this.valueDepth.filter((_item, i) => {
return !valueDepthIndices.includes(i) || this.disabledIndices.has(i);
});
}
this.updateValue(this.dataItems);
if (!this.isFocused) {
this.focus();
}
}
/**
* @hidden
*/
handleTagMapperChange(showAfter) {
this.showAfter = parseNumber(showAfter);
this.setTags();
}
/**
* @hidden
*/
clearAll(event) {
event.stopImmediatePropagation();
event.preventDefault();
this.focus();
this.value = this.value.filter((_item, index) => this.disabledIndices.has(index));
this.dataItems = this.dataItems.filter((_item, index) => this.disabledIndices.has(index));
this.valueDepth = this.valueDepth.filter((_depth, index) => this.disabledIndices.has(index));
this.emitValueChange(this.value);
}
/**
* @hidden
*/
writeValue(value) {
if (!this.valuePrimitive && isPresent(value)) {
this.dataItems = value;
}
if (!isPresent(value) && isPresent(this.value)) {
this.dataItems = null;
}
this.value = 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) {
this.filter = input.value;
this.filterChange.next(input.value);
}
/**
* @hidden
*/
get filterInputClasses() {
return `${this.size ? getSizeClass('input', this.size) : ''} ${this.fillMode ? 'k-input-' + this.fillMode : ''} ${this.rounded ? getRoundedClass(this.rounded) : ''}`;
}
/**
* @hidden
*/
get checkAllCheckboxClasses() {
return `${this.size ? getSizeClass('checkbox', this.size) : ''}`;
}
/**
* @hidden
*/
toggleCheckAll() {
this.checkAllInput.nativeElement.focus();
this.checkAllInput.nativeElement.click();
}
onTouchedCallback = noop;
onChangeCallback = noop;
verifySettings() {
if (!isDevMode()) {
return;
}
if (!isPresent(this.valueField) || !isPresent(this.textField)) {
throw new Error(MultiSelectTreeMessages.textAndValue);
}
if (!isArray(this.value)) {
throw new Error(MultiSelectTreeMessages.array);
}
if (this.value.length > 0) {
if (this.valuePrimitive && this.value.some(item => isObject(item))) {
throw new Error(MultiSelectTreeMessages.primitive);
}
const isEveryDataItemObject = this.dataItems.every(item => isObject(item.dataItem));
if (this.valuePrimitive && !isArray(this.dataItems)) {
throw new Error(MultiSelectTreeMessages.dataItems);
}
if (this.valuePrimitive && !isEveryDataItemObject) {
throw new Error(MultiSelectTreeMessages.dataItems);
}
if (this.valuePrimitive && this.dataItems.length !== this.value.length) {
throw new Error(MultiSelectTreeMessages.dataItemsLength);
}
if (!this.valuePrimitive && !isObjectArray(this.value)) {
throw new Error(MultiSelectTreeMessages.object);
}
if ((isArray(this.valueField) || isArray(this.textField)) && !isArray(this.valueDepth)) {
throw new Error(MultiSelectTreeMessages.valueDepth);
}
if ((isArray(this.valueField) || isArray(this.textField)) && this.valueDepth.length === 0) {
throw new Error(MultiSelectTreeMessages.valueDepth);
}
if ((isArray(this.valueField) || isArray(this.textField)) && this.valueDepth.length !== this.value.length) {
throw new Error(MultiSelectTreeMessages.valueDepthLength);
}
}
}
areNodesHidden(nodes) {
return nodes.every((node, index) => !this.isVisible(node, String(index)));
}
emitValueChange(value) {
this.onChangeCallback(value);
this.valueChange.emit(value);
}
triggerPopupEvents(open) {
const eventArgs = new PreventableEvent();
if (hasObservers(this.open) || hasObservers(this.close)) {
this._zone.run(() => {
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;
if (!this.appendTo) {
this.renderer.setAttribute(popupWrapper, 'role', 'region');
this.renderer.setAttribute(popupWrapper, 'aria-label', this.messageFor('popupLabel'));
}
popupWrapper.style.minWidth = min;
popupWrapper.style.width = max;
popupWrapper.style.height = this.height;
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-controls', this.treeViewId);
this.renderer.setAttribute(popupWrapper, 'dir', this.direction);
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', 'true');
this.popupRef.popupOpen.subscribe(() => {
this.cdr.detectChanges();
this.opened.emit();
this._searchableNodes = getSearchableItems(this.treeViewId, this.popupRef.popupElement);
});
this.popupRef.popupClose.subscribe(() => {
if (hasObservers(this.closed)) {
this._zone.run(() => {
this.closed.emit();
});
}
});
}
destroyPopup() {
if (this.isActionSheetExpanded) {
this.closeActionSheet();
}
if (this.popupRef) {
this.popupRef.close();
this.popupRef = null;
this.renderer.setAttribute(this.wrapper.nativeElement, 'aria-expanded', 'false');
this.renderer.removeAttribute(this.wrapper.nativeElement, 'aria-controls');
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
if ((this.popupRef && this.popupRef.popupElement.contains(e.target))