UNPKG

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