@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
JavaScript
/**-----------------------------------------------------------------------------------------
* 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`—Controls the popup animation. By default, the open and close animations are enabled.
* - `width: Number | String`—Sets the width of the popup container. By default, the width of the host element is used. If set to `auto`, the component automatically adjusts the width of the popup and no item labels are wrapped. The `auto` mode is not supported when virtual scrolling is enabled.
* - `height: Number`—Sets the height of the popup container.
* - `popupClass: String`—Specifies a list of CSS classes that are used to style the popup.
* - `appendTo: "root" | "component" | ViewContainerRef`—Specifies the component to which the popup will be appended.
*/
set popupSettings(settings) {
this._popupSettings = Object.assign({ 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—
* 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