@progress/kendo-angular-listbox
Version:
Kendo UI for Angular ListBox
662 lines (647 loc) • 29 kB
JavaScript
/**-----------------------------------------------------------------------------------------
* Copyright © 2025 Progress Software Corporation. All rights reserved.
* Licensed under commercial license. See LICENSE.md in the project root for more information
*-------------------------------------------------------------------------------------------*/
/* eslint-disable @typescript-eslint/no-inferrable-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, HostBinding, Input, isDevMode, NgZone, Output, QueryList, Renderer2, ViewChild, ViewChildren } from '@angular/core';
import { validatePackage } from '@progress/kendo-licensing';
import { Subscription } from 'rxjs';
import { packageMetadata } from './package-metadata';
import { ListBoxSelectionService } from './selection.service';
import { ItemTemplateDirective } from './item-template.directive';
import { defaultItemDisabled, fieldAccessor, getTools } from './util';
import { allTools, DEFAULT_TOOLBAR_POSITION, sizeClassMap, actionsClasses } from './constants';
import { ButtonComponent } from '@progress/kendo-angular-buttons';
import { KeyboardNavigationService } from './keyboard-navigation.service';
import { take } from 'rxjs/operators';
import { L10N_PREFIX, LocalizationService } from '@progress/kendo-angular-l10n';
import { caretAltLeftIcon, caretAltRightIcon } from '@progress/kendo-svg-icons';
import { ItemSelectableDirective } from './item-selectable.directive';
import { NgIf, NgFor } from '@angular/common';
import { LocalizedMessagesDirective } from './localization/localized-messages.directive';
import { TemplateContextDirective } from '@progress/kendo-angular-common';
import * as i0 from "@angular/core";
import * as i1 from "./keyboard-navigation.service";
import * as i2 from "./selection.service";
import * as i3 from "@progress/kendo-angular-l10n";
const DEFAULT_SIZE = 'medium';
let idx = 0;
/**
* Represents the [Kendo UI ListBox component for Angular]({% slug overview_listbox %}).
*/
export class ListBoxComponent {
keyboardNavigationService;
selectionService;
hostElement;
renderer;
zone;
localization;
changeDetector;
/**
* @hidden
*/
listboxClassName = true;
/**
* @hidden
*/
direction;
/**
* @hidden
*/
itemTemplate;
/**
* @hidden
*/
listboxElement;
/**
* @hidden
*/
listboxItems;
/**
* @hidden
*/
toolbarElement;
/**
* @hidden
*/
tools;
/**
* The fields of the data item that provide the text content of the nodes.
*/
textField;
/**
* The data which will be displayed by the ListBox.
*/
data = [];
/**
* Sets the size of the component.
*
* The possible values are:
* - `'small'`
* - `'medium'` (default)
* - `'large'`
*/
set size(size) {
const newSize = size ? size : DEFAULT_SIZE;
this.renderer.removeClass(this.hostElement.nativeElement, `k-listbox-${sizeClassMap[this.size]}`);
this.setSizingClass(newSize);
this._size = size;
}
get size() {
return this._size;
}
/**
* Sets whether a toolbar should be displayed with the ListBox, as well as what tools and position should be used.
*/
set toolbar(config) {
let position = DEFAULT_TOOLBAR_POSITION;
if (typeof config === 'boolean') {
this.selectedTools = config ? allTools : [];
}
else {
this.selectedTools = config.tools ? getTools(config.tools) : allTools;
if (config.position) {
position = config.position;
}
}
this.setToolbarClass(position);
}
/**
* The value of the aria-label attribute of the Listbox element.
*/
listboxLabel = 'Listbox';
/**
* The value of the aria-label attribute of the Listbox toolbar element.
*/
listboxToolbarLabel = 'Toolbar';
/**
* A function which determines if a specific item is disabled.
*/
itemDisabled = defaultItemDisabled;
/**
* Fires when the user selects a different ListBox item. Also fires when a node is moved, since that also changes its index.
*/
selectionChange = new EventEmitter();
/**
* Fires when the user clicks a ListBox item.
*/
actionClick = new EventEmitter();
/**
* @hidden
*/
getChildListbox = new EventEmitter();
/**
* @hidden
*/
get listClasses() {
return `k-list k-list-${sizeClassMap[this.size]}`;
}
/**
* @hidden
*/
messageFor(key) {
return this.localization.get(key);
}
/**
* @hidden
*/
selectedTools = allTools;
/**
* @hidden
*/
listboxId;
/**
* @hidden
*/
toolbarId;
/**
* @hidden
*/
childListbox;
/**
* @hidden
*/
parentListbox;
/**
* @hidden
*/
caretAltLeftIcon = caretAltLeftIcon;
/**
* @hidden
*/
caretAltRightIcon = caretAltRightIcon;
localizationSubscription;
_size = DEFAULT_SIZE;
subs = new Subscription();
shouldFireFocusIn = true;
constructor(keyboardNavigationService, selectionService, hostElement, renderer, zone, localization, changeDetector) {
this.keyboardNavigationService = keyboardNavigationService;
this.selectionService = selectionService;
this.hostElement = hostElement;
this.renderer = renderer;
this.zone = zone;
this.localization = localization;
this.changeDetector = changeDetector;
validatePackage(packageMetadata);
this.setToolbarClass(DEFAULT_TOOLBAR_POSITION);
this.setSizingClass(this.size);
this.direction = localization.rtl ? 'rtl' : 'ltr';
}
ngOnInit() {
// This event emitter gives us the connectedWith value from the DataBinding directive
this.getChildListbox.emit();
if (this.childListbox) {
// This allows us to know to which parent Listbox the child Listbox is connected to
this.childListbox.parentListbox = this;
}
if (this.selectedIndex) {
this.keyboardNavigationService.focusedToolIndex = this.selectedIndex;
}
this.localizationSubscription = this.localization.changes.subscribe(({ rtl }) => {
this.direction = rtl ? 'rtl' : 'ltr';
});
this.subs.add(this.localizationSubscription);
}
ngAfterViewInit() {
const toolsRef = this.tools.toArray();
const hostEl = this.hostElement.nativeElement;
const navService = this.keyboardNavigationService;
this.setIds();
this.initSubscriptions(navService, hostEl, toolsRef);
}
ngOnDestroy() {
this.subs.unsubscribe();
}
/**
* @hidden
*/
performAction(actionName) {
const isActionTransferFrom = actionName === 'transferFrom' || actionName === 'transferAllFrom';
const isListboxChild = this.parentListbox && !this.childListbox;
const isListboxParentAndChild = !!(this.parentListbox && this.childListbox);
const isListboxParent = !!(this.childListbox || (!this.childListbox && !this.parentListbox));
if (isListboxChild || (isListboxParentAndChild && isActionTransferFrom)) {
this.parentListbox.actionClick.next(actionName);
}
else if (isListboxParent || (isListboxParentAndChild && !isActionTransferFrom)) {
this.actionClick.next(actionName);
}
const toolsRef = this.tools.toArray() || this.parentListbox.tools.toArray();
const focusedToolIndex = toolsRef.findIndex(elem => elem.nativeElement === document.activeElement);
if ((this.selectedTools.length > 0 || this.parentListbox.selectedTools.length > 0) && focusedToolIndex > -1) {
const navService = this.keyboardNavigationService || this.parentListbox.keyboardNavigationService;
const selectedTools = this.selectedTools || this.parentListbox.selectedTools;
const prevTool = toolsRef[navService.focusedToolIndex]?.element;
navService.focusedToolIndex = selectedTools.findIndex(tool => tool.name === actionName);
const currentTool = toolsRef[navService.focusedToolIndex]?.element;
navService.changeTabindex(prevTool, currentTool);
}
}
/**
* Programmatically selects a ListBox node.
*/
selectItem(index) {
this.selectionService.selectedIndex = index;
}
/**
* Programmatically clears the ListBox selection.
*/
clearSelection() {
this.selectionService.clearSelection();
}
/**
* The index of the currently selected item in the ListBox.
*/
get selectedIndex() {
return this.selectionService.selectedIndex;
}
/**
* @hidden
*/
get getListboxId() {
const id = ++idx;
const listboxId = `k-listbox-${id}`;
return listboxId;
}
/**
* @hidden
*/
getText(dataItem) {
if (typeof dataItem !== 'string' && !this.textField && isDevMode()) {
throw new Error('Missing textField input. When passing an array of objects as data, please set the textField input of the ListBox accordingly.');
}
return fieldAccessor(dataItem, this.textField);
}
/**
* @hidden
*/
toolIcon(icon) {
return this.direction === 'ltr' ?
icon :
icon === 'caret-alt-left' ?
'caret-alt-right' :
icon === 'caret-alt-right' ?
'caret-alt-left' :
icon;
}
/**
* @hidden
*/
toolSVGIcon(icon) {
return this.direction === 'ltr' ?
icon :
icon === this.caretAltLeftIcon ?
this.caretAltRightIcon :
icon === this.caretAltRightIcon ?
this.caretAltLeftIcon :
icon;
}
onClickEvent(prevIndex, index) {
this.shouldFireFocusIn = false;
this.selectionChange.next({ index, prevIndex: this.keyboardNavigationService.selectedListboxItemIndex });
this.keyboardNavigationService.selectedListboxItemIndex = index;
this.keyboardNavigationService.focusedListboxItemIndex = index;
this.zone.onStable.pipe(take(1)).subscribe(() => {
const listboxItems = this.listboxItems.toArray();
const previousItem = prevIndex ? listboxItems[prevIndex].nativeElement : listboxItems[0].nativeElement;
const currentItem = listboxItems[index].nativeElement;
this.keyboardNavigationService.changeTabindex(previousItem, currentItem);
});
this.zone.onStable.pipe(take(1)).subscribe(() => {
this.shouldFireFocusIn = true;
});
}
initSubscriptions(navService, hostEl, toolsRef) {
this.subs.add(navService.onShiftSelectedItem.subscribe((actionToPerform) => this.performAction(actionToPerform)));
this.subs.add(navService.onTransferAllEvent.subscribe((actionToPerform) => this.performAction(actionToPerform)));
this.subs.add(this.selectionService.onSelect.subscribe((e) => this.onClickEvent(e.prevIndex, e.index)));
this.subs.add(navService.onDeleteEvent.subscribe((index) => this.onDeleteEvent(index, navService)));
this.subs.add(navService.onMoveSelectedItem.subscribe((dir) => this.performAction(dir)));
if (this.listboxElement) {
this.subs.add(this.renderer.listen(this.listboxElement.nativeElement, 'focusin', (event) => this.onFocusIn(event)));
}
this.subs.add(this.renderer.listen(hostEl, 'keydown', (event) => navService.onKeyDown(event, toolsRef, this.selectedTools, this.childListbox, this.parentListbox, this.listboxItems.toArray())));
this.subs.add(navService.onSelectionChange.subscribe((indexes) => {
const { prevIndex, index } = indexes;
this.selectionService.selectedIndex = index;
this.selectionChange.next({ index, prevIndex });
this.changeDetector.markForCheck();
}));
}
onFocusIn(event) {
const navService = this.keyboardNavigationService;
if (navService.focusedListboxItemIndex === navService.selectedListboxItemIndex && this.shouldFireFocusIn) {
const items = this.listboxItems.toArray();
const index = items.findIndex(elem => elem.nativeElement === event.target);
if (index === -1) {
return;
}
this.selectionService.selectedIndex = index;
this.selectionChange.next({ index, prevIndex: null });
const previousItem = items[navService.selectedListboxItemIndex]?.nativeElement;
const currentItem = items[index]?.nativeElement;
this.renderer.setAttribute(previousItem, 'tabindex', '-1');
this.renderer.setAttribute(currentItem, 'tabindex', '0');
}
}
setIds() {
if (!this.listboxElement) {
return;
}
const listbox = this.listboxElement.nativeElement;
this.listboxId = this.getListboxId;
this.renderer.setAttribute(listbox, 'id', this.listboxId);
if (this.selectedTools.length > 0 || this.parentListbox?.selectedTools.length > 0) {
const toolbar = this.toolbarElement?.nativeElement;
const parentToolbar = this.parentListbox?.toolbarElement?.nativeElement;
if (this.parentListbox && this.childListbox) {
this.zone.onStable.pipe(take(1)).subscribe(() => {
this.toolbarId = `${this.parentListbox.listboxId} ${this.listboxId} ${this.childListbox.listboxId}`;
this.renderer.setAttribute(toolbar, 'aria-controls', this.toolbarId);
});
}
else if (this.childListbox && !this.parentListbox) {
this.zone.onStable.pipe(take(1)).subscribe(() => {
this.toolbarId = this.toolbarId = `${this.listboxId} ${this.childListbox.listboxId}`;
this.renderer.setAttribute(toolbar, 'aria-controls', this.toolbarId);
});
}
else if (this.parentListbox && this.selectedTools.length > 0) {
this.toolbarId = `${this.parentListbox.listboxId} ${this.listboxId}`;
this.parentListbox.toolbarId = this.toolbarId = `${this.parentListbox.listboxId} ${this.listboxId}`;
this.renderer.setAttribute(toolbar, 'aria-controls', this.toolbarId);
parentToolbar && this.renderer.setAttribute(parentToolbar, 'aria-controls', this.parentListbox.toolbarId);
}
else if (!this.parentListbox && !this.childListbox) {
this.toolbarId = this.listboxId;
this.renderer.setAttribute(toolbar, 'aria-controls', this.toolbarId);
}
}
}
onDeleteEvent(index, navService) {
this.selectionService.selectedIndex = index;
this.performAction('remove');
const listboxItems = this.listboxItems.toArray();
const setIndex = index + 1 === listboxItems.length ?
{ index: index - 1, tabindex: index - 1 } : { index, tabindex: index + 1 };
navService.changeTabindex(null, listboxItems[setIndex['tabindex']]?.nativeElement);
this.selectionChange.next({ index: setIndex['index'], prevIndex: null });
navService.selectedListboxItemIndex = setIndex['index'];
navService.focusedListboxItemIndex = setIndex['index'];
navService.focusedListboxItem = setIndex['index'];
this.selectionService.selectedIndex = setIndex['index'];
}
setToolbarClass(pos) {
Object.keys(actionsClasses).forEach((className) => {
if (pos === className) {
this.renderer.addClass(this.hostElement.nativeElement, actionsClasses[className]);
}
else {
this.renderer.removeClass(this.hostElement.nativeElement, actionsClasses[className]);
}
});
}
setSizingClass(size) {
this.renderer.addClass(this.hostElement.nativeElement, `k-listbox-${sizeClassMap[size]}`);
}
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ListBoxComponent, deps: [{ token: i1.KeyboardNavigationService }, { token: i2.ListBoxSelectionService }, { token: i0.ElementRef }, { token: i0.Renderer2 }, { token: i0.NgZone }, { token: i3.LocalizationService }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: ListBoxComponent, isStandalone: true, selector: "kendo-listbox", inputs: { textField: "textField", data: "data", size: "size", toolbar: "toolbar", listboxLabel: "listboxLabel", listboxToolbarLabel: "listboxToolbarLabel", itemDisabled: "itemDisabled" }, outputs: { selectionChange: "selectionChange", actionClick: "actionClick", getChildListbox: "getChildListbox" }, host: { properties: { "class.k-listbox": "this.listboxClassName", "attr.dir": "this.direction" } }, providers: [
ListBoxSelectionService,
KeyboardNavigationService,
LocalizationService,
{
provide: L10N_PREFIX,
useValue: 'kendo.listbox'
},
], queries: [{ propertyName: "itemTemplate", first: true, predicate: ItemTemplateDirective, descendants: true }], viewQueries: [{ propertyName: "listboxElement", first: true, predicate: ["listbox"], descendants: true }, { propertyName: "toolbarElement", first: true, predicate: ["toolbar"], descendants: true }, { propertyName: "listboxItems", predicate: ["listboxItems"], descendants: true }, { propertyName: "tools", predicate: ["tools"], descendants: true }], ngImport: i0, template: `
<ng-container kendoListBoxLocalizedMessages
i18n-moveUp="kendo.listbox.moveUp|The title of the Move Up button"
moveUp="Move Up"
i18n-moveDown="kendo.listbox.moveDown|The title of the Move Down button"
moveDown="Move Down"
i18n-transferTo="kendo.listbox.transferTo|The title of the Transfer To button"
transferTo="Transfer To"
i18n-transferAllTo="kendo.listbox.transferAllTo|The title of the Transfer All To button"
transferAllTo="Transfer All To"
i18n-transferFrom="kendo.listbox.transferFrom|The title of the Transfer From button"
transferFrom="Transfer From"
i18n-transferAllFrom="kendo.listbox.transferAllFrom|The title of the Transfer All From button"
transferAllFrom="Transfer All From"
i18n-remove="kendo.listbox.remove|The title of the Remove button"
remove="Remove"
i18n-noDataText="kendo.listbox.noDataText|The text displayed when there are no items"
noDataText="No data found."
>
</ng-container>
<div
#toolbar
class="k-listbox-actions"
*ngIf="selectedTools.length > 0"
role="toolbar"
[attr.aria-label]="listboxToolbarLabel"
>
<button
#tools
*ngFor="let tool of selectedTools; let i = index"
kendoButton
[attr.tabindex]="i === 0 ? '0' : '-1'"
[size]="this.size"
[icon]="toolIcon(tool.icon)"
[svgIcon]="toolSVGIcon(tool.svgIcon)"
[attr.title]="messageFor(tool.name)"
(click)="performAction(tool.name)"
role="button"
type="button"
></button>
</div>
<div class="k-list-scroller k-selectable">
<div class="{{ listClasses }}">
<div
*ngIf="data.length > 0"
class="k-list-content"
>
<ul
#listbox
class="k-list-ul"
role="listbox"
[attr.aria-label]="listboxLabel"
[attr.aria-multiselectable]="false"
>
<li
#listboxItems
*ngFor="let item of data; let i = index"
kendoListBoxItemSelectable
class="k-list-item"
[attr.tabindex]="i === 0 ? '0' : '-1'"
role="option"
[attr.aria-selected]="selectedIndex === i"
[index]="i"
[class.k-disabled]="itemDisabled(item)"
>
<ng-template
*ngIf="itemTemplate; else defaultItemTemplate"
[templateContext]="{
templateRef: itemTemplate.templateRef,
$implicit: item
}"
>
</ng-template>
<ng-template #defaultItemTemplate>
<span class="k-list-item-text">{{ getText(item) }}</span>
</ng-template>
</li>
</ul>
</div>
<span
*ngIf="data.length === 0"
class="k-nodata"
>{{ messageFor('noDataText') }}</span>
</div>
</div>
`, isInline: true, dependencies: [{ kind: "directive", type: LocalizedMessagesDirective, selector: "[kendoListBoxLocalizedMessages]" }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "component", type: ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon", "primary", "look"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "directive", type: ItemSelectableDirective, selector: "[kendoListBoxItemSelectable]", inputs: ["index"] }, { kind: "directive", type: TemplateContextDirective, selector: "[templateContext]", inputs: ["templateContext"] }] });
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ListBoxComponent, decorators: [{
type: Component,
args: [{
selector: 'kendo-listbox',
providers: [
ListBoxSelectionService,
KeyboardNavigationService,
LocalizationService,
{
provide: L10N_PREFIX,
useValue: 'kendo.listbox'
},
],
template: `
<ng-container kendoListBoxLocalizedMessages
i18n-moveUp="kendo.listbox.moveUp|The title of the Move Up button"
moveUp="Move Up"
i18n-moveDown="kendo.listbox.moveDown|The title of the Move Down button"
moveDown="Move Down"
i18n-transferTo="kendo.listbox.transferTo|The title of the Transfer To button"
transferTo="Transfer To"
i18n-transferAllTo="kendo.listbox.transferAllTo|The title of the Transfer All To button"
transferAllTo="Transfer All To"
i18n-transferFrom="kendo.listbox.transferFrom|The title of the Transfer From button"
transferFrom="Transfer From"
i18n-transferAllFrom="kendo.listbox.transferAllFrom|The title of the Transfer All From button"
transferAllFrom="Transfer All From"
i18n-remove="kendo.listbox.remove|The title of the Remove button"
remove="Remove"
i18n-noDataText="kendo.listbox.noDataText|The text displayed when there are no items"
noDataText="No data found."
>
</ng-container>
<div
#toolbar
class="k-listbox-actions"
*ngIf="selectedTools.length > 0"
role="toolbar"
[attr.aria-label]="listboxToolbarLabel"
>
<button
#tools
*ngFor="let tool of selectedTools; let i = index"
kendoButton
[attr.tabindex]="i === 0 ? '0' : '-1'"
[size]="this.size"
[icon]="toolIcon(tool.icon)"
[svgIcon]="toolSVGIcon(tool.svgIcon)"
[attr.title]="messageFor(tool.name)"
(click)="performAction(tool.name)"
role="button"
type="button"
></button>
</div>
<div class="k-list-scroller k-selectable">
<div class="{{ listClasses }}">
<div
*ngIf="data.length > 0"
class="k-list-content"
>
<ul
#listbox
class="k-list-ul"
role="listbox"
[attr.aria-label]="listboxLabel"
[attr.aria-multiselectable]="false"
>
<li
#listboxItems
*ngFor="let item of data; let i = index"
kendoListBoxItemSelectable
class="k-list-item"
[attr.tabindex]="i === 0 ? '0' : '-1'"
role="option"
[attr.aria-selected]="selectedIndex === i"
[index]="i"
[class.k-disabled]="itemDisabled(item)"
>
<ng-template
*ngIf="itemTemplate; else defaultItemTemplate"
[templateContext]="{
templateRef: itemTemplate.templateRef,
$implicit: item
}"
>
</ng-template>
<ng-template #defaultItemTemplate>
<span class="k-list-item-text">{{ getText(item) }}</span>
</ng-template>
</li>
</ul>
</div>
<span
*ngIf="data.length === 0"
class="k-nodata"
>{{ messageFor('noDataText') }}</span>
</div>
</div>
`,
standalone: true,
imports: [LocalizedMessagesDirective, NgIf, NgFor, ButtonComponent, ItemSelectableDirective, TemplateContextDirective]
}]
}], ctorParameters: function () { return [{ type: i1.KeyboardNavigationService }, { type: i2.ListBoxSelectionService }, { type: i0.ElementRef }, { type: i0.Renderer2 }, { type: i0.NgZone }, { type: i3.LocalizationService }, { type: i0.ChangeDetectorRef }]; }, propDecorators: { listboxClassName: [{
type: HostBinding,
args: ['class.k-listbox']
}], direction: [{
type: HostBinding,
args: ['attr.dir']
}], itemTemplate: [{
type: ContentChild,
args: [ItemTemplateDirective]
}], listboxElement: [{
type: ViewChild,
args: ['listbox']
}], listboxItems: [{
type: ViewChildren,
args: ['listboxItems']
}], toolbarElement: [{
type: ViewChild,
args: ['toolbar']
}], tools: [{
type: ViewChildren,
args: ['tools']
}], textField: [{
type: Input
}], data: [{
type: Input
}], size: [{
type: Input
}], toolbar: [{
type: Input
}], listboxLabel: [{
type: Input
}], listboxToolbarLabel: [{
type: Input
}], itemDisabled: [{
type: Input
}], selectionChange: [{
type: Output
}], actionClick: [{
type: Output
}], getChildListbox: [{
type: Output
}] } });