@progress/kendo-angular-dropdowns
Version:
A wide variety of native Angular dropdown components including AutoComplete, ComboBox, DropDownList, DropDownTree, MultiColumnComboBox, MultiSelect, and MultiSelectTree
1,402 lines • 93.3 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
import { 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, 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 { AdaptiveRendererComponent } from '../common/adaptive-renderer.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 for Angular [MultiSelectTree]({% slug overview_multiselecttree %}) component.
*
* The `MultiSelectTree` lets you select multiple items from hierarchical data in a tree structure.
* It provides built-in filtering, checkboxes, and adaptive rendering for mobile devices.
*
* @example
* ```typescript
* @Component({
* selector: 'my-app',
* template: `
* <kendo-multiselecttree
* [data]="data"
* textField="text"
* valueField="value"
* [(value)]="selectedValues">
* </kendo-multiselecttree>
* `
* })
* export class AppComponent {
* public data = [
* { text: 'Root', value: 1, items: [
* { text: 'Child 1', value: 2 },
* { text: 'Child 2', value: 3 }
* ]}
* ];
* public selectedValues = [2, 3];
* }
* ```
* @remarks
* Supported children components are: {@link CustomMessagesComponent}.
*/
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.filter = text;
this.filterChange.emit(text);
}
}
/**
* Controls the adaptive mode behavior of the component.
* Set to `auto` to enable automatic adaptive rendering on small screens.
*
* @default 'none'
*/
adaptiveMode = 'none';
/**
* Sets the title text for the ActionSheet in adaptive mode on small screens.
* Uses the component label by default if not set.
*
* @default ''
*/
adaptiveTitle = '';
/**
* Sets the subtitle text for the ActionSheet in adaptive mode on small screens.
* No subtitle appears by default.
*/
adaptiveSubtitle;
/**
* @hidden
*/
get isAdaptiveModeEnabled() {
return this.adaptiveMode === 'auto';
}
/**
* @hidden
*/
windowSize = 'large';
/**
* @hidden
*/
get isActionSheetExpanded() {
return this.actionSheet?.expanded;
}
/**
* @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
*/
adaptiveRendererComponent;
/**
* @hidden
*/
get actionSheet() {
return this.adaptiveRendererComponent?.actionSheet;
}
/**
* @hidden
*/
get actionSheetSearchBar() {
return this.adaptiveRendererComponent?.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;
/**
* Sets the tab index for keyboard navigation.
* Use `-1` to remove the component from the tab sequence.
*
* @default 0
*/
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 visual size of the component.
*
* @default 'medium'
*/
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 style of the component.
*
* @default 'medium'
*/
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 fill style for the component background and borders.
*
* @default 'solid'
*/
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 container settings: animation, dimensions, styling and positioning.
*/
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;
}
/**
* Configures the checkbox behavior for the MultiSelecTree nodes.
* Use `checkableSettings` to control parent-child selection relationships and click interactions.
*
* @default '{ checkChildren: true, checkOnClick: true }'
*/
set checkableSettings(settings) {
this._checkableSettings = Object.assign({}, DEFAULT_CHECKABLE_SETTINGS, settings);
}
get checkableSettings() {
return this._checkableSettings;
}
/**
* Sets the hierarchical data source for the tree structure.
* Provide an array of objects that contain the tree data and structure.
*/
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 selected values in the component.
* Accepts primitive values if `valuePrimitive` is `true`, or objects if `false`.
*/
set value(value) {
this._value = value ? value : [];
this.setState();
}
get value() {
return this._value;
}
/**
* Sets the data items that correspond to the selected values.
* Required when using primitive values to resolve the full data objects.
*/
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
};
});
}
/**
* Specifies which data field provides the display text for tree nodes.
* > The `textField` property can be set to point to a nested property value - e.g. `category.name`.
*/
textField;
/**
* Specifies which data field provides the unique values for tree nodes.
* > 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.
*
* @default []
*/
valueDepth = [];
/**
* Controls the loading state visual indicator.
* Shows a loading spinner when set to `true`.
*
* @default false
*/
loading;
/**
* Sets the placeholder text shown when no items are selected.
* Helps guide users on what action to take.
*
* @default ''
*/
placeholder = '';
/**
* Sets the maximum height of the options list in the popup.
* Controls vertical scrolling when content exceeds this height.
*
* @default 200
*/
set listHeight(_listHeight) {
this._listHeight = _listHeight;
}
get listHeight() {
if (this.isAdaptive) {
return;
}
return this._listHeight;
}
_listHeight = 200;
/**
* Controls whether the component accepts user input.
* Prevents all user interactions when set to `true`.
*
* @default false
*/
disabled = false;
/**
* Sets the component to read-only mode.
* Displays current selections but prevents changes.
*
* @default false
*/
readonly = false;
/**
* Determines the data type of selected values.
* Set to `true` for primitive values, false for complex objects.
*
* @default false
*/
valuePrimitive = false;
/**
* Controls when child nodes load from the data source.
* Set to `true` to load children only when parent nodes expand.
*
* @default false
*/
loadOnDemand = false;
/**
* Sets the unique identifier for the focusable element.
* Used internally for accessibility and label association.
*/
focusableId = `k-${guid()}`;
/**
* Shows a clear button to reset all selections.
* Appears on hover when selections exist and the component is not disabled.
*
* @default true
*/
clearButton = true;
/**
* Enables the built-in filter input for searching tree nodes.
* Shows a search box above the tree when enabled.
*
* @default false
*/
filterable = false;
/**
* Shows a checkbox to select or deselect all visible tree nodes.
* Appears above the tree when checkboxes are enabled.
*
* @default false
*/
checkAll = false;
/**
* Determines if a tree node contains child nodes.
* Return `true` if the node has children, false otherwise.
*/
hasChildren = hasChildren;
/**
* Function that provides child nodes for a parent node.
* Return an Observable of child objects for the given parent.
*/
fetchChildren = fetchChildren;
/**
* Determines if a specific node is expanded. The function is executed for each data item.
*/
isNodeExpanded;
/**
* Determines if a tree node should be hidden.
*/
isNodeVisible = isNodeVisible;
/**
* Determines if a tree node is disabled. The function is executed for each data item.
*/
itemDisabled = itemDisabled;
/**
* @param { Any[] } dataItems - The selected data items from the list.
* @returns { Any[] } - The tags that will be rendered by the component.
* Transforms the provided array of data items into an array of tags.
*/
tagMapper = (tags) => tags || [];
/**
* Fires when the component receives focus.
*/
onFocus = new EventEmitter();
/**
* Fires when the component gets blurred.
*/
onBlur = new EventEmitter();
/**
* Fires when the popup is about to open. ([See example]({% slug openstate_multiselecttree %})).
* This event is preventable. When cancelled, the popup remains closed.
*/
open = new EventEmitter();
/**
* Fires after the popup opens completely.
* Use this event to perform actions when the popup becomes visible.
*/
opened = new EventEmitter();
/**
* Fires before the popup closes.
* Cancel this event to prevent the popup from closing.
*/
close = new EventEmitter();
/**
* Fires after the popup closes completely.
* Use this event to perform cleanup actions when the popup becomes hidden.
*/
closed = new EventEmitter();
/**
* Fires when a tree node is expanded.
* Use this event to respond to node expansion actions.
*/
nodeExpand = new EventEmitter();
/**
* Fires when a user collapses a tree node.
* Use this event to respond to node collapse actions.
*/
nodeCollapse = new EventEmitter();
/**
* Fires when the selected value changes.
* Use this event to respond to selection changes.
*/
valueChange = new EventEmitter();
/**
* Fires before a tag is removed.
* Cancel this event to prevent tag removal.
*/
removeTag = new EventEmitter();
/**
* Fires when the filter input value changes.
* Use this event to implement custom filtering logic.
*/
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;
_nodes;
_value = [];
_tabindex = 0;
_popupSettings = DEFAULT_POPUP_SETTINGS;
_checkableSettings = DEFAULT_CHECKABLE_SETTINGS;
_isFocused = false;
_treeview;
_dataItems;
_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().startsWith(this._typedValue);
});
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();
}
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)));
}
/**
* @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);
}
}
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);
}
/**
* Sets focus to the component.
* Call this method to programmatically focus the MultiSelectTree.
*/
focus() {
if (!this.disabled) {
this.wrapper.nativeElement.focus();
}
}
/**
* Removes focus from the component.
* Call this method to programmatically blur the MultiSelectTree.
*/
blur() {
if (!this.disabled) {
this.wrapper.nativeElement.blur();
}
}
/**
* Sets focus to a specific tree item by index.
* Provide the item index in format like '1_1' to focus that item.
* The item must be expanded and enabled to receive focus.
*/
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);
}
}
}
/**
* Clears all selected values from the component.
* This method does not trigger the valueChange event.
*/
reset() {
this.value = [];
this.dataItems = [];
this.valueDepth = [];
}
/**
* Opens or closes the popup programmatically.
* Pass `true` to open, false to close, or omit the parameter to toggle.
* This method does not trigger open or close events.
*/
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');
this.clearFilter();
}
}
clearFilter() {
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)) || this.isActionSheetExpanded) {
return;
}
this.togglePopup(!this.isOpen);
}
subscribeEvents() {
[
this.navigationService.open.subscribe((event) => {
event.originalEvent.preventDefault();
this.togglePopup(true);
}),
this.navigationService.enter
.pipe(tap((event) => event.originalEvent.preventDefault()))
.subscribe(() => this.togglePopup(true)),
merge(this.navigationService.close, this.navigationService.esc).subscribe((event) => {
event.originalEvent.preventDefault();
this.focus();
this.togglePopup(false);
}),
this.navigationService.tab.subscribe(this.handleTabKey.bind(this)),
this.navigationService.up.subscribe(this.handleUpKey.bind(this)),
this.navigationService.down.subscribe(this.handleDownKey.bind(this)),
this.navigationService.left
.pipe(filter(() => !this.isTreeViewActive))
.subscribe(this.direction === 'rtl' ? this.handleRightKey.bind(this) : this.handleLeftKey.bind(this)),
this.navigationService.right
.pipe(filter(() => !this.isTreeViewActive))
.subscribe(this.direction === 'rtl' ? this.handleLeftKey.bind(this) : this.handleRightKey.bind(this)),
this.navigationService.home.pipe(filter(() => !this.isOpen)).subscribe(this.handleHome.bind(this)),
this.navigationService.end.pipe(filter(() => !this.isOpen)).subscribe(this.handleEnd.bind(this)),
this.navigationService.backspace.pipe(filter(() => this.isTagFocused)).subscribe(this.handleBackspace.bind(this)),
this.navigationService.delete.pipe(filter(() => this.isTagFocused)).subscribe(this.handleDelete.bind(this))
].forEach(sub => this.subs.add(sub));
}
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);
}
}
handleDocumentBlur(event) {
if (event.target !== this.wrapper.nativeElement) {
return;
}
event.stopImmediatePropagation();
this.handleBlur(event);
}
handleTabKey() {
if (!this.isActionSheetExpanded) {
this.focus();
}
if (this.isOpen) {
this.treeview.b