UNPKG

@progress/kendo-angular-dropdowns

Version:

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

1,361 lines 97.4 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { guid, isPresent, isArray, isObjectArray, resolveAllValues, selectedIndices, getter, isNumber, isUntouched, inDropDown, getSizeClass, getRoundedClass, getFillModeClass, animationDuration, isTruthy, setListBoxAriaLabelledBy, setActionSheetTitle } from '../common/util'; import { SearchBarComponent } from '../common/searchbar.component'; import { ViewChild, Renderer2, ViewContainerRef, Component, HostBinding, Input, ElementRef, TemplateRef, Output, EventEmitter, isDevMode, forwardRef, ContentChild, ChangeDetectorRef, KeyValueDiffers, NgZone, Injector } from '@angular/core'; import { Subscription, Subject, of, merge } from 'rxjs'; import { isChanged, isDocumentAvailable, KendoInput, hasObservers, anyChanged, SuffixTemplateDirective, PrefixTemplateDirective, isControlRequired, SeparatorComponent, ResizeSensorComponent, Keys, TemplateContextDirective } from '@progress/kendo-angular-common'; import { AdaptiveService } from '@progress/kendo-angular-utils'; import { catchError, filter, map, take, tap } from 'rxjs/operators'; import { NG_VALUE_ACCESSOR, NgControl } from '@angular/forms'; import { validatePackage } from '@progress/kendo-licensing'; import { packageMetadata } from '../package-metadata'; import { SelectionService } from '../common/selection/selection.service'; import { NavigationService } from '../common/navigation/navigation.service'; import { NavigationAction } from '../common/navigation/navigation-action'; import { DisabledItemsService } from '../common/disabled-items/disabled-items.service'; import { ItemTemplateDirective } from '../common/templates/item-template.directive'; import { CustomItemTemplateDirective } from '../common/templates/custom-item-template.directive'; import { GroupTemplateDirective } from '../common/templates/group-template.directive'; import { FixedGroupTemplateDirective } from '../common/templates/fixed-group-template.directive'; import { HeaderTemplateDirective } from '../common/templates/header-template.directive'; import { FooterTemplateDirective } from '../common/templates/footer-template.directive'; import { TagTemplateDirective } from '../common/templates/tag-template.directive'; import { GroupTagTemplateDirective } from '../common/templates/group-tag-template.directive'; import { NoDataTemplateDirective } from '../common/templates/no-data-template.directive'; import { MultiselectMessages } from '../common/constants/error-messages'; import { PreventableEvent } from '../common/models/preventable-event'; import { RemoveTagEvent } from '../common/models/remove-tag-event'; import { LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n'; import { PopupService } from '@progress/kendo-angular-popup'; import { FilterableComponent } from '../common/filtering/filterable-component'; import { DataService } from '../common/data.service'; import { ListComponent } from '../common/list.component'; import { normalizeCheckboxesSettings } from '../common/models/checkboxes-settings'; import { normalizeVirtualizationSettings } from '../common/models/virtualization-settings'; import { xIcon } from '@progress/kendo-svg-icons'; import { AdaptiveRendererComponent } from '../common/adaptive-renderer.component'; import { TagListComponent } from '../common/taglist.component'; import { NgIf, NgTemplateOutlet } from '@angular/common'; import { SharedDropDownEventsDirective } from '../common/shared-events.directive'; import { LocalizedMessagesDirective } from '../common/localization/localized-messages.directive'; import { IconWrapperComponent } from '@progress/kendo-angular-icons'; import { touchEnabled } from '@progress/kendo-common'; import * as i0 from "@angular/core"; import * as i1 from "@progress/kendo-angular-l10n"; import * as i2 from "@progress/kendo-angular-popup"; import * as i3 from "../common/data.service"; import * as i4 from "../common/selection/selection.service"; import * as i5 from "../common/navigation/navigation.service"; import * as i6 from "../common/disabled-items/disabled-items.service"; import * as i7 from "@progress/kendo-angular-utils"; const MULTISELECT_VALUE_ACCESSOR = { multi: true, provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MultiSelectComponent) }; const DEFAULT_SIZE = 'medium'; const DEFAULT_ROUNDED = 'medium'; const DEFAULT_FILL_MODE = 'solid'; /** * Represents the [Kendo UI MultiSelect component for Angular]({% slug overview_multiselect %}). * * @example * ```ts * _@Component({ * selector: 'my-app', * template: ` * <kendo-multiselect [data]="listItems"> * </kendo-multiselect> * ` * }) * class AppComponent { * public listItems: Array<string> = ["Item 1", "Item 2", "Item 3", "Item 4"]; * } * ``` */ export class MultiSelectComponent { wrapper; localization; popupService; dataService; selectionService; navigationService; disabledItemsService; cdr; differs; renderer; _zone; injector; hostElement; adaptiveService; /** * @hidden */ animationDuration = animationDuration; /** * @hidden */ xIcon = xIcon; listBoxId = `k-${guid()}`; tagListId = `k-${guid()}`; tagPrefix = "tag-" + guid(); optionPrefix = "option-" + guid(); popupRef; text; tags; focusedTagIndex = undefined; /** * @hidden */ adaptiveRendererComponent; /** * @hidden */ get actionSheet() { return this.adaptiveRendererComponent?.actionSheet; } /** * @hidden */ get actionSheetSearchBar() { return this.adaptiveRendererComponent?.actionSheetSearchBar; } /** * @hidden */ get ariaControls() { return this.isOpen ? this.listBoxId : undefined; } /** * @hidden */ get isControlRequired() { return isControlRequired(this.formControl); } /** * @hidden */ get formControl() { const ngControl = this.injector.get(NgControl, null); return ngControl?.control || null; } /** * Focuses a specific item of the MultiSelect based on a provided index. * If there is a custom item it is positioned at index -1. * If null or invalid index is provided the focus will be removed. */ focusItemAt(index) { const minIndex = this.allowCustom ? -1 : 0; const isInRange = minIndex <= 0 && index < this.data.length; if (isPresent(index) && isInRange && !this.disabledItemsService.isIndexDisabled(index)) { this.selectionService.focus(index); } else { this.selectionService.focus(null); } } /** * Focuses the MultiSelect. */ focus() { if (!this.disabled) { this.searchbar.focus(); } } /** * @hidden */ handleInputFocus() { this.handleFocus(); if (hasObservers(this.inputFocus)) { this._zone.run(() => { this.inputFocus.emit(); }); } } /** * Blurs the MultiSelect. */ blur() { if (!this.disabled) { this.searchbar.blur(); } } /** * @hidden */ handleFocus() { this._zone.run(() => { if (!this.isFocused && hasObservers(this.onFocus)) { this.onFocus.emit(); } this.isFocused = true; }); } /** * @hidden */ handleBlur() { if (!this.isActionSheetExpanded) { this.blurComponent(); } } /** * @hidden */ handleInputBlur() { if (!this.isActionSheetExpanded) { if (!this.isFocused) { return; } if (hasObservers(this.inputBlur) || hasObservers(this.filterChange) || hasObservers(this.close) || isUntouched(this.wrapper.nativeElement) || this.formControl?.updateOn === 'blur') { this._zone.run(() => { this.closePopup(); if (!(this.isOpen && this.allowCustom)) { this.clearFilter(); } this.inputBlur.emit(); this.onTouchedCallback(); }); } else { if (!this.allowCustom) { this.clearFilter(); } this.closePopup(); } } } /** * @hidden */ onPointerDown(event) { event.preventDefault(); } /** * @hidden */ onResize() { const currentWindowSize = this.adaptiveService.size; if (this.isAdaptiveModeEnabled && this.windowSize !== currentWindowSize) { if (this.isOpen) { this.togglePopup(false); } this.windowSize = currentWindowSize; this.cdr.detectChanges(); } if (this.isOpen && !this.isActionSheetExpanded) { const popupWrapper = this.popupRef.popupElement; const { min, max } = this.width; popupWrapper.style.minWidth = min; popupWrapper.style.width = max; } } get appendTo() { const { appendTo } = this.popupSettings; if (!appendTo || appendTo === 'root') { return undefined; } return appendTo === 'component' ? this.container : appendTo; } /** * Shows or hides the current group sticky header when using grouped data. * By default the sticky header is displayed ([see example]({% slug grouping_autocomplete %}#toc-sticky-header)). */ showStickyHeader = true; /** * @hidden */ focusableId = `k-${guid()}`; /** * Determines whether to close the options list of the MultiSelect after the item selection is finished * ([see example]({% slug openstate_multiselect %}#toc-keeping-the-options-list-open-while-on-focus)). * @default true */ autoClose = true; /** * Sets and gets the loading state of the MultiSelect. */ loading; /** * Sets the data of the MultiSelect. * * > The data has to be provided in an array-like list of items. */ set data(data) { this.dataService.data = data || []; if (this.virtual) { this.virtual.skip = 0; } if (this.initialized) { this.setState(this.value); } } get data() { const virtual = this.virtual; if (virtual) { const start = virtual.skip || 0; const end = start + virtual.pageSize; //Use length instead of itemsCount because of grouping virtual.total = this.dataService.data.length; return this.dataService.data.slice(start, end); } return this.dataService.data; } /** * Sets the value of the MultiSelect. It can be either of the primitive (string, numbers) or of the complex (objects) type. * To define the type, use the `valuePrimitive` option. * * > All selected values which are not present in the source are ignored. */ set value(values) { this._value = values ? values : []; if (!this.differ && this.value) { this.differ = this.differs.find(this.value).create(); } this.valueChangeDetected = true; if (this.initialized) { this.setState(this.value); } } get value() { return this._value; } /** * Sets the data item field that represents the item value. * If the data contains only primitive values, do not define it. * * > The `valueField` property can be set to point to a nested property value - e.g. `category.id`. */ valueField; /** * Sets the data item field that represents the item text. * If the data contains only primitive values, do not define it. * * > The `textField` property can be set to point to a nested property value - e.g. `category.name`. */ textField; /** * Specifies the [`tabindex`](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/tabindex) of the component. */ tabindex = 0; /** * @hidden */ set tabIndex(tabIndex) { this.tabindex = tabIndex; } get tabIndex() { return this.tabindex; } /** * Sets the size of the component. * * The possible values are: * * `small` * * `medium` (default) * * `large` * * `none` * */ set size(size) { const newSize = size ? size : DEFAULT_SIZE; this.renderer.removeClass(this.wrapper.nativeElement, getSizeClass('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; } /** * The hint which is displayed when the component is empty. * When the values are selected, it disappears. */ set placeholder(text) { this._placeholder = text || ''; } get placeholder() { return this.selectedDataItems.length ? '' : this._placeholder; } /** * 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 MultiSelect. */ adaptiveTitle = ''; /** * Sets the subtitle of the ActionSheet that is rendered instead of the Popup when using small screen devices. * By default the ActionSheet does not render a subtitle. */ adaptiveSubtitle; /** * @hidden */ get isAdaptiveModeEnabled() { return this.adaptiveMode === 'auto'; } /** * 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_multiselect#toc-managing-the-multiselect-disabled-state-in-reactive-forms). */ disabled = false; /** * Defines a Boolean function that is executed for each data item in the component * ([see examples]({% slug disableditems_multiselect %})). Determines whether the item will be disabled. */ set itemDisabled(fn) { if (typeof fn !== 'function') { throw new Error(`itemDisabled must be a function, but received ${JSON.stringify(fn)}.`); } this.disabledItemsService.itemDisabled = fn; } /** * Specifies whether checkboxes will be rendered before each item in the popup list. */ set checkboxes(settings) { this._checkboxes = normalizeCheckboxesSettings(settings); } get checkboxes() { return this._checkboxes; } /** * Sets the read-only state of the component. * * @default false */ readonly = false; /** * Enables the [filtering]({% slug filtering_multiselect %}) functionality of the MultiSelect. */ filterable = false; /** * Enables the [virtualization]({% slug virtualization_multiselect %}) functionality. */ set virtual(settings) { this._virtualSettings = normalizeVirtualizationSettings(settings); } get virtual() { return this._virtualSettings; } /** * Configures the popup of the MultiSelect. * * The available options are: * - `animate: Boolean`&mdash;Controls the popup animation. By default, the open and close animations are enabled. * - `width: Number | String`&mdash;Sets the width of the popup container. By default, the width of the host element is used. If set to `auto`, the component automatically adjusts the width of the popup and no item labels are wrapped. The `auto` mode is not supported when virtual scrolling is enabled. * - `height: Number`&mdash;Sets the height of the popup container. * - `popupClass: String`&mdash;Specifies a list of CSS classes that are used to style the popup. * - `appendTo: "root" | "component" | ViewContainerRef`&mdash;Specifies the component to which the popup will be appended. */ set popupSettings(settings) { this._popupSettings = Object.assign({ animate: true }, settings); } get popupSettings() { return this._popupSettings; } /** * Sets the height of the options list in the popup. By default, `listHeight` is 200px. * * > The `listHeight` property affects only the list of options and not the whole popup container. * > To set the height of the popup container, use `popupSettings.height`. * * > When using `adaptiveMode` and the screen size is `small` or `medium` the `listHeight` property is set to null. */ set listHeight(_listHeight) { this._listHeight = _listHeight; } get listHeight() { if (this.isAdaptive) { return; } return this._listHeight; } _listHeight = 200; /** * Specifies the type of the selected value. * If set to `true`, the selected value has to be of the primitive type * ([more information and example]({% slug valuebinding_multiselect %}#toc-primitive-values-from-object-fields)). */ set valuePrimitive(isPrimitive) { this._valuePrimitive = isPrimitive; } get valuePrimitive() { if (!isPresent(this._valuePrimitive)) { return !isPresent(this.valueField); } return this._valuePrimitive; } /** * If set to `true`, renders a button on hovering over the component. * Clicking this button resets the value of the component to an empty array and triggers the `change` event. */ clearButton = true; /** * A user-defined callback function which receives an array of selected data items and maps them to an array of tags * ([see examples]({% slug summarytagmode_multiselect %}#toc-rendering-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 || []; /** * Specifies whether the MultiSelect allows user-defined values that are not present in the dataset * ([more information and examples]({% slug custom_values_multiselect %})). * Defaults to `false`. * * The feature is not available when using adaptive mode. */ allowCustom = false; /** * A user-defined callback function which returns normalized custom values. * Typically used when the data items are different from type `string`. * * @param { Any } value - The custom value that is defined by the user. * @returns { Any } * * @example * ```ts * import { map } from 'rxjs/operators'; * * _@Component({ * selector: 'my-app', * template: ` * <kendo-multiselect * [allowCustom]="true" * [data]="listItems" * textField="text" * valueField="value" * [valueNormalizer]="valueNormalizer" * (valueChange)="onValueChange($event)" * > * </kendo-multiselect> * ` * }) * * class AppComponent { * public listItems: Array<{ text: string, value: number }> = [ * { text: "Small", value: 1 }, * { text: "Medium", value: 2 }, * { text: "Large", value: 3 } * ]; * * public onValueChange(value) { * console.log("valueChange : ", value); * } * * public valueNormalizer = (text$: Observable<string>) => text$.pipe(map((text: string) => { * return { * value: Math.floor(Math.random() * (1000 - 100) + 1000), //generate unique valueField * text: text }; * })); * * } * ``` */ valueNormalizer = (text) => text.pipe(map((userInput) => { const comparer = (item) => typeof item === 'string' && userInput.toLowerCase() === item.toLowerCase(); const matchingValue = this.value.find(comparer); if (matchingValue) { return matchingValue; } const matchingItem = this.dataService.find(comparer); return matchingItem ? matchingItem : userInput; })); /** * Sets the HTML attributes of the inner focusable input element. Attributes which are essential for certain component functionalities cannot be changed. */ inputAttributes; /** * Fires each time the user types in the input field. * You can filter the source based on the passed filtration value. */ filterChange = new EventEmitter(); /** * Fires each time the value is changed&mdash; * when the component is blurred or the value is cleared through the **Clear** button * ([see example](slug:events_multiselect)). * When the value of the component is programmatically changed to `ngModel` or `formControl` * through its API or form binding, the `valueChange` event is not triggered because it * might cause a mix-up with the built-in `valueChange` mechanisms of the `ngModel` or `formControl` bindings. */ valueChange = new EventEmitter(); /** * Fires each time the popup is about to open * ([see example]({% slug openstate_multiselect %}#toc-preventing-opening-and-closing)). * This event is preventable. If you cancel it, the popup will remain closed. */ open = new EventEmitter(); /** * Fires after the popup has been opened. */ opened = new EventEmitter(); /** * Fires each time the popup is about to close * ([see example]({% slug openstate_multiselect %}#toc-preventing-opening-and-closing)). * This event is preventable. If you cancel it, the popup will remain open. */ close = new EventEmitter(); /** * Fires after the popup has been closed. */ closed = new EventEmitter(); /** * Fires each time the user focuses the MultiSelect. */ onFocus = new EventEmitter(); /** * Fires each time the MultiSelect gets blurred. */ onBlur = new EventEmitter(); /** * Fires each time the user focuses the `input` element. */ inputFocus = new EventEmitter(); /** * Fires each time the `input` element gets blurred. */ inputBlur = new EventEmitter(); /** * Fires each time a tag is about to be removed([see examples]({% slug summarytagmode_multiselecttree %}#toc-notifying-on-removing-group-tags)). * This event is preventable. If you cancel it, the tag will not be removed. */ removeTag = new EventEmitter(); container; searchbar; tagList; popupTemplate; optionsList; template; customItemTemplate; groupTemplate; fixedGroupTemplate; headerTemplate; footerTemplate; tagTemplate; groupTagTemplate; noDataTemplate; /** * @hidden */ suffixTemplate; /** * @hidden */ prefixTemplate; hostClasses = true; get dir() { return this.direction; } get disabledClass() { return this.disabled; } get isLoading() { return this.loading; } /** * @hidden */ windowSize = 'large'; /** * @hidden */ get isActionSheetExpanded() { return this.actionSheet?.expanded; } /** * @hidden */ get isAdaptive() { return this.isAdaptiveModeEnabled && this.windowSize !== 'large'; } /** * @hidden */ applyValue() { if (!this.isActionSheetExpanded) { return; } const isValueChanged = !(this.value.every(i => this._valueHolder.find(item => i === item)) && this._valueHolder.length === this.value.length); if (isValueChanged) { this.value = this._valueHolder; this._valueHolder = []; this.emitValueChange(); } this.clearFilter(); this.togglePopup(false); if (this.allowCustom && this.isCustomValueSelected) { this.isCustomValueSelected = false; } } disabledIndices; initialized = false; _size = 'medium'; _rounded = 'medium'; _fillMode = 'solid'; _valueHolder = []; isCustomValueSelected = false; constructor(wrapper, localization, popupService, dataService, selectionService, navigationService, disabledItemsService, cdr, differs, renderer, _zone, injector, hostElement, adaptiveService) { this.wrapper = wrapper; this.localization = localization; this.popupService = popupService; this.dataService = dataService; this.selectionService = selectionService; this.navigationService = navigationService; this.disabledItemsService = disabledItemsService; this.cdr = cdr; this.differs = differs; this.renderer = renderer; this._zone = _zone; this.injector = injector; this.hostElement = hostElement; this.adaptiveService = adaptiveService; validatePackage(packageMetadata); this.popupPointerDownHandler = this.onPointerDown.bind(this); this.data = []; this.direction = this.localization.rtl ? 'rtl' : 'ltr'; this.subscribeEvents(); this.subscribeTouchEvents(); } get listContainerClasses() { const containerClasses = ['k-list-container', 'k-multiselect-popup']; if (this.popupSettings.popupClass) { containerClasses.push(this.popupSettings.popupClass); } return containerClasses; } /** * @hidden */ get customItemSizeClass() { const currentSize = this.isAdaptive ? 'large' : this.size; return `${currentSize ? getSizeClass('list', currentSize) : ''}`; } get width() { let wrapperOffsetWidth = 0; if (isDocumentAvailable()) { wrapperOffsetWidth = this.wrapper.nativeElement.offsetWidth; } const width = this.popupSettings.width || wrapperOffsetWidth; const minWidth = isNaN(wrapperOffsetWidth) ? wrapperOffsetWidth : `${wrapperOffsetWidth}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 activeDescendant() { const focusedTagIndex = this.focusedTagIndex; const focusedListIndex = this.selectionService.focused; let prefix; let focusedIndex; if (isPresent(focusedTagIndex) && !this.isOpen) { focusedIndex = focusedTagIndex; prefix = this.tagPrefix; } else if (isPresent(focusedListIndex) && focusedListIndex !== -1 && this.isOpen) { focusedIndex = focusedListIndex; prefix = this.optionPrefix; } else { return null; } return prefix + "-" + focusedIndex; } get clearButtonVisiblity() { if (touchEnabled) { return 'visible'; } } /** * @hidden */ verifySettings() { if (!isDevMode() || this.value.length === 0) { return; } if (!isArray(this.value)) { throw new Error(MultiselectMessages.array); } if (this.valuePrimitive === true && isObjectArray(this.value)) { throw new Error(MultiselectMessages.primitive); } if (this.valuePrimitive === false && !isObjectArray(this.value)) { throw new Error(MultiselectMessages.object); } const valueOrText = !isPresent(this.valueField) !== !isPresent(this.textField); if (valueOrText) { throw new Error(MultiselectMessages.textAndValue); } } /** * @hidden */ change(event) { if (event.isMultipleSelection) { // Existing items. if (isPresent(event.added) && event.added.length > 0) { event.added.forEach((itemIndex) => { const dataItem = this.dataService.itemAt(itemIndex); const newItem = (this.valuePrimitive && isPresent(dataItem) && isPresent(getter(dataItem, this.valueField))) ? getter(dataItem, this.valueField) : dataItem; if (newItem) { if (!this.isAdaptive || !this.isActionSheetExpanded) { this.value = [...this.value, newItem]; } else { this._valueHolder = [...this._valueHolder, newItem]; } } }); } if (isPresent(event.removed) && event.removed.length > 0) { event.removed.forEach((itemIndex) => { const dataItem = this.dataService.itemAt(itemIndex); const filter = (item) => getter(item, this.valueField) !== getter(dataItem, this.valueField); if (!this.isAdaptive || !this.isActionSheetExpanded) { this.value = this.value.filter(filter); } else { this._valueHolder = this._valueHolder.filter(filter); } }); this.cdr.detectChanges(); } } else { const isCustomItem = (isPresent(event.added) || isPresent(event.removed)) && (event.added === -1 || event.removed === -1); if (isCustomItem) { this.addCustomValue(this.text); return; // The change is emited asynchronosly. } // Existing items. if (isPresent(event.added)) { const dataItem = this.dataService.itemAt(event.added); const newItem = (this.valuePrimitive && isPresent(dataItem) && isPresent(getter(dataItem, this.valueField))) ? getter(dataItem, this.valueField) : dataItem; if (!this.isAdaptive || !this.isActionSheetExpanded) { this.value = [...this.value, newItem]; } else { this._valueHolder = [...this._valueHolder, newItem]; } } if (isPresent(event.removed)) { const dataItem = this.dataService.itemAt(event.removed); const filter = (item) => getter(item, this.valueField) !== getter(dataItem, this.valueField); if (!this.isAdaptive || !this.isActionSheetExpanded) { this.value = this.value.filter(filter); } else { this._valueHolder = this._valueHolder.filter(filter); } this.selectionService.focused = event.removed; this.cdr.detectChanges(); } } if (!this.isAdaptive || !this.isActionSheetExpanded) { this.emitValueChange(); } } /** * @hidden */ setState(value) { let data = this.dataService.data; if (this.dataService.grouped) { data = data.filter(item => !item.header).map(item => item.value); } const selection = selectedIndices(this.value, data, this.valueField); this.selectionService.resetSelection(selection); if (this.disabledItemsService.isIndexDisabled(this.selectionService.focused)) { this.selectionService.focused = this.firstFocusableIndex(0); } if (this.isOpen && this.selectionService.focused === undefined) { if (this.dataService.itemsCount > 0) { this.selectionService.focused = this.firstFocusableIndex(0); if (this.allowCustom) { this.selectionService.focused = 0; } } else if (this.allowCustom) { this.selectionService.focused = -1; } } if (this.valuePrimitive && !this.valueField) { this.selectedDataItems = value.slice(); } if (isObjectArray(value) || this.valuePrimitive && this.valueField) { this.selectedDataItems = resolveAllValues(value, data, this.valueField); } if (this.selectedDataItems.length < value.length) { this.selectedDataItems = value .map(current => { const dataItem = this.selectedDataItems.find(item => getter(item, this.valueField) === getter(current, this.valueField)); return isPresent(dataItem) ? dataItem : this.resolveDataItemFromTags(current); }) .filter(dataItem => isPresent(dataItem)); } this.tags = this.tagMapper(this.selectedDataItems.slice(0)); this.disabledIndices = this.disabledItemsMapper(); this.cdr.markForCheck(); } /** * @hidden */ handleFilter(text) { if (this.isActionSheetExpanded && this.allowCustom && this.isCustomValueSelected) { this.addCustomValue(this.text); this.addCustomValue(text); } this.text = text; if (text && !this.isOpen) { this.openPopup(); } if (this.filterable) { this.filterChange.emit(text); } else { this.searchTextAndFocus(text); } this.searchbar.setInputSize(); } /** * @hidden */ pageChange(event) { const virtual = this.virtual; virtual.skip = event.skip; } /** * @hidden */ clearFilter() { if (this.filterable && this.text) { this.filterChange.emit(""); } this.text = ""; /* Clearing the value from the input as the setInputSize calculation will be incorrect otherwise. Calling cdr.detectChanges to clear the input value as a result of property binding causes JAWS to read outdated tag values in IE upon tag selection for some reason. */ this.searchbar.input.nativeElement.value = ""; this.searchbar.setInputSize(); } /** * @hidden */ handleNavigate(event) { const navigateInput = this.text && event.keyCode !== Keys.ArrowDown && event.keyCode !== Keys.ArrowUp; const selectValue = this.text && event.keyCode === Keys.Enter || event.keyCode === Keys.Escape; const deleteTag = !this.text && event.keyCode === Keys.Backspace && this.tags.length > 0; if (event.keyCode === Keys.Backspace && this.isActionSheetExpanded) { return; } if (deleteTag) { this.handleBackspace(); return; } if (this.disabled || navigateInput && !selectValue) { return; } const eventData = event; const focused = isNaN(this.selectionService.focused) ? -1 : this.selectionService.focused; const action = this.navigationService.process({ current: focused, max: this.dataService.itemsCount - 1, min: this.allowCustom && this.text ? -1 : 0, open: this.isOpen, originalEvent: eventData }); if (action !== NavigationAction.Undefined && ((action === NavigationAction.Enter && this.isOpen) || action !== NavigationAction.Enter) && (!this.isActionSheetExpanded && action === NavigationAction.Tab)) { event.preventDefault(); } } /** * @hidden */ handleRemoveTag({ tag }) { const eventArgs = new RemoveTagEvent(tag); if (this.disabled || this.readonly) { return; } this.focus(); this.removeTag.emit(eventArgs); if (eventArgs.isDefaultPrevented()) { return; } if (tag instanceof Array) { this.removeGroupTag(tag); } else { this.removeSingleTag(tag); } this.cdr.detectChanges(); } /** * @hidden */ clearAll(event) { event?.stopImmediatePropagation(); event?.preventDefault(); this.focus(); this.clearFilter(); this.selectionService.lastClickedIndex = null; const selected = this.selectionService.selected; this.value = this.value.filter((_item, index) => this.disabledItemsService.isIndexDisabled(selected[index])); this.emitValueChange(); } /** * @hidden */ addCustomValue(text) { this.customValueSubject.next(text); } ngAfterContentChecked() { this.verifySettings(); } ngDoCheck() { const valueChanges = this.differ && this.differ.diff(this.value); if (valueChanges && !this.valueChangeDetected) { this.setState(this.value); } this.valueChangeDetected = false; } ngOnInit() { this.renderer.removeAttribute(this.wrapper.nativeElement, "tabindex"); this.createCustomValueStream(); this.subs.add(this.localization .changes.subscribe(({ rtl }) => { this.direction = rtl ? 'rtl' : 'ltr'; this.cdr.markForCheck(); })); this.setState(this.value); this.setComponentClasses(); this.initialized = true; } ngOnChanges(changes) { const virtual = this.virtual; const requestInitialData = virtual && changes.data && changes.data.isFirstChange(); if (requestInitialData) { this.pageChange({ skip: 0, take: virtual.pageSize }); } if (isChanged('valueNormalizer', changes)) { this.createCustomValueStream(); } if (anyChanged(['textField', 'valueField', 'valuePrimitive'], changes)) { this.setState(this.value); } } ngAfterViewInit() { this.windowSize = this.adaptiveService.size; this.cdr.detectChanges(); this.searchbar.setInputSize(); this.subs.add(this.renderer.listen(this.wrapper.nativeElement, 'mousedown', this.handleMousedown.bind(this))); this.subs.add(this.renderer.listen(this.wrapper.nativeElement, 'keydown', this.handleKeydown.bind(this))); this._zone.onStable.pipe(take(1)).subscribe(() => { const ariaLabel = this.searchbar.input.nativeElement.getAttribute('aria-labelledby') || this.searchbar.input.nativeElement.getAttribute('data-kendo-label-id'); if (ariaLabel) { this.renderer.setAttribute(this.tagList.hostElement.nativeElement, 'aria-labelledby', ariaLabel); } }); } ngOnDestroy() { this._toggle(false); this.unsubscribeEvents(); } /** * Toggles the visibility of the popup or actionSheet * ([see example]({% slug openstate_multiselect %}#toc-setting-the-initially-opened-component)). * If you use the `toggle` method to open or close the popup or actionSheet, the respective `open` and `close` events will not be fired. * * @param open - The state of the popup. */ toggle(open) { // The Promise is required for opening the popup on load. // Otherwise, the "Expression has changed..." type error will be thrown. Promise.resolve(null).then(() => { const shouldOpen = isPresent(open) ? open : !this._open; this._toggle(shouldOpen); this.cdr.markForCheck(); }); } /** * Returns the current open state. Returns `true` if the popup or actionSheet is open. */ get isOpen() { return isTruthy(this._open || this.isActionSheetExpanded); } /** * Resets the value of the MultiSelect. * If you use the `reset` method to clear the value of the component, * the model will not update automatically and the `selectionChange` and `valueChange` events will not be fired. */ reset() { this.text = ""; this.value = []; } /** * @hidden */ messageFor(key) { return this.localization.get(key); } // NG MODEL BINDINGS /** * @hidden */ writeValue(value) { this.value = value || []; } /** * @hidden */ registerOnChange(fn) { this.onChangeCallback = fn; } /** * @hidden */ registerOnTouched(fn) { this.onTouchedCallback = fn; } /** * @hidden */ setDisabledState(isDisabled) { this.cdr.markForCheck(); this.disabled = isDisabled; } /** * @hidden */ onTagMapperChange() { this.tags = this.tagMapper(this.selectedDataItems.slice(0)); this.cdr.markForCheck(); } /** * @hidden */ handleClick() { this.windowSize = this.adaptiveService.size; if (this.isAdaptive) { this.togglePopup(true); } } onChangeCallback = (_) => { }; onTouchedCallback = (_) => { }; _placeholder = ''; _open = false; _value = []; _popupSettings = { animate: true }; _virtualSettings; _valuePrimitive; _checkboxes = { enabled: false }; _isFocused = false; set isFocused(isFocused) { this.renderer[isFocused ? 'addClass' : 'removeClass'](this.wrapper.nativeElement, 'k-focus'); this._isFocused = isFocused; } get isFocused() { return this._isFocused; } selectedDataItems = []; popupPointerDownHandler; isOpenPrevented = false; customValueSubject = new Subject(); customValueSubscription; subs = new Subscription(); touchstartDisposeHandler; direction; differ; valueChangeDetected; subscribeEvents() { if (!isDocumentAvailable()) { return; } const isOpen = () => this.isOpen; const isClosed = () => !this.isOpen; const isTagFocused = () => !this.isOpen && this.focusedTagIndex !== undefined; [ this.selectionService.onChange.subscribe(this.handleItemChange.bind(this)), this.navigationService.esc.subscribe(this.closePopup.bind(this)), this.navigationService.enter.pipe(filter(isOpen)).subscribe(this.handleEnter.bind(this)), this.navigationService.open.subscribe(this.openPopup.bind(this)), this.navigationService.close.subscribe(this.handleClose.bind(this)), this.navigationService.up.pipe(filter(isOpen)).subscribe((event) => this.handleUp(event.index)), this.navigationService.home.pipe(filter(() => isClosed)).subscribe(this.handleHome.bind(this)), this.navigationService.end.pipe(filter(() => isClosed)).subscribe(this.handleEnd.bind(this)), this.navigationService.backspace.pipe(filter(isTagFocused)).subscribe(this.handleBackspace.bind(this)), this.navigationService.delete.pipe(filter(isTagFocused)).subscribe(this.handleDelete.bind(this)), this.navigationService.left.subscribe(this.direction === 'rtl' ? this.handleRightKey.bind(this) : this.handleLeftKey.bind(this)), this.navigationService.right.subscribe(this.direction === 'rtl' ? this.handleLeftKey.bind(this) : this.handleRightKey.bind(this)), this.navigationService.down.subscribe((event) => this.handleDownKey(event.index)), this.navigationService.selectprevious.pipe(filter(isOpen)).subscribe((event) => this.handleSelectUpDown(event)), this.navigationService.selectnext.pipe(filter(isOpen)).subscribe((event) => this.handleSelectUpDown(event)), this.navigationService.selectalltobeginning.pipe(filter(isOpen)).subscribe(() => this.handleSelectAllToBeginning()), this.navigationService.selectalltoend.pipe(filter(isOpen)).subscribe(() => this.handleSelectAllToEnd()), merge(this.navigationService.pagedown, this.navigationService.pageup).subscribe((event) => { if (this.isOpen) { event.originalEvent.preventDefault(); this.optionsList.scrollWithOnePage(NavigationAction[event.originalEvent.code]); } }) ].forEach(s => this.subs.add(s)); } subscribeTouchEvents() { if (!isDocumentAvailable() || !touchEnabled) { return; } this._zone.runOutsideAngular(() => // Roll up MultiSelect on iOS when tapped outside this.touchstartDisposeHandler = this.renderer.listen(document, 'touchstart', (e) => { const target = e.target; if ((this.isFocused || this.isOpen) && !inDropDown(this.wrapper, target, this.popupRef)) { this._zone.run(() => { this.blur(); if (this.isOpen) { this.togglePopup(false); } }); } })); } unsubscribeEvents() { if (!isDocumentAvailable()) { return; } this.subs.unsubscribe(); if (this.customValueSubscription) { this.customValueSubscription.unsubscribe(); } if (this.touchstartDisposeHandler) { this.touchstartDisposeHandler(); } } removeGroupTag(dataItems) { let data = this.dataService.data; if (this.dataService.grouped) { data = data.filter(item => !item.header).map(item => item.value); } const dataItemValues = new Set(dataItems.map(item => getter(item, this.valueField))); this.value = this.value.filter(value => { const index = selectedIndices([value], data, this.valueField)[0]; const isDataItemDisabled = this.disabledItemsService.isIndexDisabled(index); return !dataItemValues.has(getter(value, this.valueField)) || isDataItemDisabled; }); this.emitValueChange(); } removeSingleTag(dataItem) { let data = this.dataService.data; if (this.dataService.grouped) { data = data.filter(item => !item.header).map(item => item.value); } const index = selectedIndices([dataItem], data, this.valueField)[0]; if (this.disabledItemsService.isIndexDisabled(index)) { return; } if (isNumber(index)) { this.selectionService.deselect(index); this.selectionService.focused = index; this.togglePopup(false); } else { // the deleted item is not present in the source const filter = item => getter(item, this.valueField) !== getter(dataItem, this.valueField); this.value = this.value.filter(filter); this.emitValueChange(); } } /** * @hidden * * Determines which of the provided tags should be disabled and stores their position indices */ disabledItemsMapper() { const { selected } = this.selectionService; return new Set(this.selectedDataItems.reduce((indices, _item, index) => { if (this.disabledItemsService.isIndexDisabled(selected[index])) { indices.push(index); } return indices; }, [])); } createCustomValueStream() { if (this.customValueSubscription) { this.customValueSubscription.unsubscribe(); } this.customValueSubscription = this.customValueSubject.pipe(tap(() => { this.loading = true; this.disabled = true; this.cdr.detectChanges(); }), this.valueNormalizer, catchError(() => { this.loading = false; this.disabled = false; if (this.autoClose) { this.togglePopup(false); } if (this.autoClose || !this.filterable) { this.clearFilter(); } this.nextTick(() => { this.searchbar.focus(); }); this.createCustomValueStream(); return of(null); })) .subscribe((normalizedValue) => { this.loading