UNPKG

@progress/kendo-angular-menu

Version:

Kendo UI Angular Menu component

630 lines (623 loc) 28.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 { Component, ElementRef, Input, NgZone, Optional, Renderer2, TemplateRef, ViewContainerRef, forwardRef } from '@angular/core'; import { NgFor, NgIf, NgClass, NgStyle, NgTemplateOutlet } from '@angular/common'; import { ChangeDetectorRef, HostBinding, ViewChild } from '@angular/core'; import { isDocumentAvailable } from '@progress/kendo-angular-common'; import { POPUP_CONTAINER, PopupService } from '@progress/kendo-angular-popup'; import { IconWrapperComponent } from '@progress/kendo-angular-icons'; import { ContextMenuService } from '../context-menu/context-menu.service'; import { NODE_INDEX } from '../constants'; import { closest, closestItem, hasClass, inMenu, isFocusable, nodeIndex } from '../dom-queries'; import { ActionsService } from '../services/actions.service'; import { HoverService } from '../services/hover.service'; import { ItemsService } from '../services/items.service'; import { NavigationService } from '../services/navigation.service'; import { bodyFactory, getFontIcon, getSizeClass, getSVGIcon } from '../utils'; import { POPUP_SETTINGS, POPUP_SETTINGS_RTL } from './popup-settings'; import * as i0 from "@angular/core"; import * as i1 from "../services/items.service"; import * as i2 from "../services/hover.service"; import * as i3 from "../services/actions.service"; import * as i4 from "../services/navigation.service"; import * as i5 from "@progress/kendo-angular-popup"; import * as i6 from "../context-menu/context-menu.service"; /* eslint-disable @angular-eslint/component-selector */ /** * @hidden */ export class ListComponent { itemsService; hover; actions; navigation; renderer; ngZone; element; appendTo; items; level; index; animate = true; size = 'medium'; vertical; rtl; openOnClick; itemTemplate; itemLinkTemplate; domSubscriptions; constructor(itemsService, hover, actions, navigation, renderer, ngZone, element) { this.itemsService = itemsService; this.hover = hover; this.actions = actions; this.navigation = navigation; this.renderer = renderer; this.ngZone = ngZone; this.element = element; } hierarchyIndex(index) { return this.itemsService.itemIndex(this.index, index); } ngOnInit() { this.itemsService.addList(this); this.initDomEvents(); } ngOnDestroy() { this.itemsService.removeList(this); if (this.domSubscriptions) { this.domSubscriptions(); } } initDomEvents() { if (!isDocumentAvailable() || !this.element) { return; } this.ngZone.runOutsideAngular(() => { const element = this.element.nativeElement; const container = this.level > 0 ? closest(element, (node) => hasClass(node, 'k-popup')) : element; const overSubscription = this.renderer.listen(element, 'mouseover', (e) => { if (e.target === element && this.level === 0) { this.onLeave(); } else { const item = this.nodeItem(e.target) || this.itemsService.get(this.index); if (item && !(this.openOnClick && this.openOnClick.toggle === 'click' && item.level === 0 && !item.hasContent)) { this.hover.over(item); } } }); const leaveSubscription = this.renderer.listen(container, 'mouseleave', (e) => { if (this.leavesMenu(e)) { this.onLeave(); } }); const keydownSubscription = this.renderer.listen(element, 'keydown', (e) => { if (hasClass(e.target, 'k-menu-item')) { this.navigation.keydown(e); } }); const blurSubscription = this.renderer.listen(element, 'focusout', (e) => { if (this.leavesMenu(e)) { this.navigation.focusLeave(); } }); /** * Handle focus/blur open/close for iOS devices since it behaves inconsistently with the rest * Refer to: https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html */ const touchSubscription = this.renderer.listen(document, 'touchstart', (e) => { if (inMenu(e.target, this.itemsService)) { const item = this.nodeItem(e.target); // Needs to be called because the 'click' handler will be called only on secondary tap and the item will remain unfocused this.navigation.focus(item); // This is needed since the 'mouseover' event is not always dispatched if (!item.opened) { this.hover.over(item); } } else if (this.navigation.focusedIdx) { // If the touch is outside of the menu and the menu is not currently in focus const activeItem = this.itemsService.get(this.navigation.activeIndex); this.onLeave(); // needs to be called explicitly since mouseleave event is not triggered activeItem.blur(); // needs to be called explicitly otherwise the item remains focused => triggers focusout } }); const clickSubscription = this.renderer.listen(element, 'click', this.clickHandler.bind(this)); this.domSubscriptions = () => { overSubscription(); leaveSubscription(); keydownSubscription(); blurSubscription(); clickSubscription(); touchSubscription(); }; }); } leavesMenu(e) { if (!e.relatedTarget) { return true; } return !inMenu(e.relatedTarget, this.itemsService); } onLeave() { const openOnClick = this.openOnClick; if (!openOnClick || openOnClick.toggle !== 'click') { this.hover.leave(openOnClick && openOnClick.toggle === 'leave'); } } nodeItem(target) { const node = closestItem(target, this.element.nativeElement); if (node) { const index = nodeIndex(node); return this.itemsService.get(index); } } clickHandler(e) { if (isFocusable(e.target) && !hasClass(e.target, 'k-menu-item')) { return; } const item = this.nodeItem(e.target); if (!item || item.isContent || item.navigating) { return; } if (item.disabled) { e.preventDefault(); return; } this.actions.select(item, e, () => { e.preventDefault(); }); this.navigation.focus(item); if (item.level > 0 && !item.hasContent) { this.actions.closeToRoot(item); } if (this.openOnClick) { const hover = this.hover; if (item.opened) { if (item.level === 0) { hover.openOnOver = false; this.actions.close(item); } } else if (item.hasContent) { hover.openOnOver = true; this.actions.closeOthers(item); this.actions.open(item); } else { hover.openOnOver = false; if (item.level === 0 && this.openOnClick.toggle === 'click') { this.hover.closeCurrent(); } } } this.actions.execute(); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ListComponent, deps: [{ token: i1.ItemsService }, { token: i2.HoverService }, { token: i3.ActionsService }, { token: i4.NavigationService }, { token: i0.Renderer2 }, { token: i0.NgZone }, { token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: ListComponent, isStandalone: true, selector: "[kendoMenuList]", inputs: { appendTo: "appendTo", items: "items", level: "level", index: "index", animate: "animate", size: "size", vertical: "vertical", rtl: "rtl", openOnClick: "openOnClick", itemTemplate: "itemTemplate", itemLinkTemplate: "itemLinkTemplate" }, ngImport: i0, template: "\n <ng-container *ngFor=\"let item of items; let idx = index\">\n <li *ngIf=\"!item.separator\" \n kendoMenuItem\n [appendTo]=\"appendTo\"\n [item]=\"item\"\n [level]=\"level\"\n [size]=\"size\"\n [vertical]=\"vertical\"\n [animate]=\"animate\"\n [rtl]=\"rtl\"\n [itemTemplate]=\"itemTemplate\"\n [itemLinkTemplate]=\"itemLinkTemplate\"\n [openOnClick]=\"openOnClick\"\n [index]=\"hierarchyIndex(idx)\"\n [siblingIndex]=\"idx\"\n [attr.data-kendo-menu-index]=\"hierarchyIndex(idx)\"\n [ngClass]=\"item.cssClass\"\n [ngStyle]=\"item.cssStyle\"\n role=\"menuitem\"\n class=\"k-item k-menu-item\"\n [class.k-first]=\"idx === 0\"\n [class.k-last]=\"idx === items.length - 1\"\n [class.k-disabled]=\"item.disabled\"></li>\n <li \n *ngIf=\"item.separator\"\n class=\"k-separator k-item\"\n [ngClass]=\"item.cssClass\"\n aria-hidden=\"true\"\n [ngStyle]=\"item.cssStyle\">\n &nbsp;\n </li>\n </ng-container>\n ", isInline: true, dependencies: [{ kind: "directive", type: i0.forwardRef(function () { return NgFor; }), selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: i0.forwardRef(function () { return NgIf; }), selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: i0.forwardRef(function () { return ItemComponent; }), selector: "[kendoMenuItem]", inputs: ["appendTo", "item", "level", "index", "siblingIndex", "animate", "size", "vertical", "rtl", "openOnClick", "itemTemplate", "itemLinkTemplate"] }, { kind: "directive", type: i0.forwardRef(function () { return NgClass; }), selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: i0.forwardRef(function () { return NgStyle; }), selector: "[ngStyle]", inputs: ["ngStyle"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ListComponent, decorators: [{ type: Component, args: [{ selector: '[kendoMenuList]', template: ` <ng-container *ngFor="let item of items; let idx = index"> <li *ngIf="!item.separator" kendoMenuItem [appendTo]="appendTo" [item]="item" [level]="level" [size]="size" [vertical]="vertical" [animate]="animate" [rtl]="rtl" [itemTemplate]="itemTemplate" [itemLinkTemplate]="itemLinkTemplate" [openOnClick]="openOnClick" [index]="hierarchyIndex(idx)" [siblingIndex]="idx" [attr.${NODE_INDEX}]="hierarchyIndex(idx)" [ngClass]="item.cssClass" [ngStyle]="item.cssStyle" role="menuitem" class="k-item k-menu-item" [class.k-first]="idx === 0" [class.k-last]="idx === items.length - 1" [class.k-disabled]="item.disabled"></li> <li *ngIf="item.separator" class="k-separator k-item" [ngClass]="item.cssClass" aria-hidden="true" [ngStyle]="item.cssStyle"> &nbsp; </li> </ng-container> `, standalone: true, imports: [NgFor, NgIf, forwardRef(() => ItemComponent), NgClass, NgStyle] }] }], ctorParameters: function () { return [{ type: i1.ItemsService }, { type: i2.HoverService }, { type: i3.ActionsService }, { type: i4.NavigationService }, { type: i0.Renderer2 }, { type: i0.NgZone }, { type: i0.ElementRef }]; }, propDecorators: { appendTo: [{ type: Input }], items: [{ type: Input }], level: [{ type: Input }], index: [{ type: Input }], animate: [{ type: Input }], size: [{ type: Input }], vertical: [{ type: Input }], rtl: [{ type: Input }], openOnClick: [{ type: Input }], itemTemplate: [{ type: Input }], itemLinkTemplate: [{ type: Input }] } }); /** * @hidden */ export class ItemComponent { itemsService; navigation; changeDetector; renderer; popupService; element; contextService; appendTo; item; level; set index(index) { if (this._index && this._index !== index) { this.itemsService.remove(this); this._index = index; this.itemsService.add(this); } else { this._index = index; } this.childId = this.itemsService.childId(index); } get index() { return this._index; } siblingIndex; animate = true; size = 'medium'; vertical; rtl; openOnClick = false; itemTemplate; itemLinkTemplate; link; popupTemplate; get disabled() { return this.item.disabled; } get hasPopup() { return this.hasContent ? true : null; } get label() { return this.item.text ? this.item.text : null; } get popupSettings() { const settings = this.rtl ? POPUP_SETTINGS_RTL : POPUP_SETTINGS; return this.horizontal ? settings.horizontal : settings.vertical; } get horizontal() { return this.vertical || this.level > 0; } get hasLink() { return Boolean(this.item.url); } get linkTemplate() { return this.item.linkTemplate || this.itemLinkTemplate; } get hasContent() { const items = this.item.items; return items && items.length || this.item.contentTemplate; } get isContent() { return Boolean(this.item.content); } get iconClass() { return `k-i-${this.item.icon}`; } get isContextMenu() { return Boolean(this.contextService); } get menuListClasses() { const sizeClass = getSizeClass(this.size); return this.isContextMenu ? `k-context-menu k-menu-group ${sizeClass}` : `k-menu-group ${sizeClass}`; } get children() { const item = this.item; if (item.contentTemplate) { if (!this.contentItems) { this.contentItems = [{ content: item.contentTemplate, owner: item, ownerIndex: this.index }]; } return this.contentItems; } return item.items; } get template() { return this.item.template || this.itemTemplate; } /** * @hidden */ fontIcon = getFontIcon; /** * @hidden */ SVGIcon = getSVGIcon; opened = false; navigating = false; childId; contentItems; popupRef; _index; destroyed = false; constructor(itemsService, navigation, changeDetector, renderer, popupService, element, contextService) { this.itemsService = itemsService; this.navigation = navigation; this.changeDetector = changeDetector; this.renderer = renderer; this.popupService = popupService; this.element = element; this.contextService = contextService; } hasContentTemplates() { const item = this.item; return this.itemTemplate || item.contentTemplate || this.itemLinkTemplate || (item.items && item.items.find(current => current.template || current.linkTemplate)); } ngOnInit() { this.itemsService.add(this); } ngAfterViewInit() { if (this.hasContent) { this.setAttribute('aria-expanded', this.opened.toString()); } this.index === this.navigation.activeIndex ? this.setAttribute('tabindex', '0') : this.setAttribute('tabindex', '-1'); } ngOnDestroy() { this.itemsService.remove(this); this.destroyed = true; if (this.popupRef) { this.popupRef.close(); this.popupRef = null; } } focus() { this.element.nativeElement.focus(); } blur() { this.element.nativeElement.blur(); } toggleActive(isActive) { if (isActive) { this.setAttribute('tabindex', '0'); } else { this.setAttribute('tabindex', '-1'); } } open() { if (!this.destroyed && this.hasContent && !this.opened) { const popupSettings = this.popupSettings; const animate = this.animate ? Object.assign({}, this.animate, { direction: popupSettings.animate }) : false; this.opened = true; this.popupRef = this.popupService.open({ appendTo: this.appendTo, popupAlign: popupSettings.popup, anchorAlign: popupSettings.anchor, collision: popupSettings.collision, anchor: this.element, positionMode: 'absolute', content: this.popupTemplate, popupClass: { 'k-rtl': this.rtl, 'k-menu-popup': true }, animate: animate }); this.setAttribute('aria-expanded', 'true'); this.setAttribute('aria-controls', this.childId); this.changeDetector.detectChanges(); } } close() { if (!this.destroyed && this.opened) { this.opened = false; if (this.popupRef) { this.popupRef.close(); this.popupRef = null; } this.changeDetector.detectChanges(); this.setAttribute('aria-expanded', 'false'); this.renderer.removeAttribute(this.element.nativeElement, 'aria-controls'); } } navigate() { let link; if (this.linkTemplate) { link = this.element.nativeElement.querySelector('a.k-menu-link'); } else if (this.hasLink) { link = this.link.nativeElement; } if (link) { this.navigating = true; link.click(); this.navigating = false; } } setAttribute(name, value) { this.renderer.setAttribute(this.element.nativeElement, name, value); } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ItemComponent, deps: [{ token: i1.ItemsService }, { token: i4.NavigationService }, { token: i0.ChangeDetectorRef }, { token: i0.Renderer2 }, { token: i5.PopupService }, { token: i0.ElementRef }, { token: i6.ContextMenuService, optional: true }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: ItemComponent, isStandalone: true, selector: "[kendoMenuItem]", inputs: { appendTo: "appendTo", item: "item", level: "level", index: "index", siblingIndex: "siblingIndex", animate: "animate", size: "size", vertical: "vertical", rtl: "rtl", openOnClick: "openOnClick", itemTemplate: "itemTemplate", itemLinkTemplate: "itemLinkTemplate" }, host: { properties: { "attr.aria-disabled": "this.disabled", "attr.aria-haspopup": "this.hasPopup", "attr.aria-label": "this.label" } }, providers: [PopupService, { provide: POPUP_CONTAINER, useFactory: bodyFactory }], viewQueries: [{ propertyName: "link", first: true, predicate: ["link"], descendants: true }, { propertyName: "popupTemplate", first: true, predicate: ["popupTemplate"], descendants: true, static: true }], ngImport: i0, template: ` <span *ngIf="!hasLink && !item.content && !linkTemplate" class="k-link k-menu-link" #link [class.k-active]="opened" role="presentation"> <ng-template [ngTemplateOutlet]="itemcontent"> </ng-template> </span> <a *ngIf="item.url && !linkTemplate" class="k-link k-menu-link" #link [attr.href]="item.url" [class.k-active]="opened" tabindex="-1" role="presentation"> <ng-template [ngTemplateOutlet]="itemcontent"> </ng-template> </a> <ng-template *ngIf="linkTemplate && !item.content" [ngTemplateOutlet]="linkTemplate" [ngTemplateOutletContext]="{ item: item, index: index }"> </ng-template> <div class="k-content" *ngIf="item.content" role="presentation"> <ng-template [ngTemplateOutlet]="item.content" [ngTemplateOutletContext]="{ item: item.owner, index: item.ownerIndex }"> </ng-template> </div> <ng-template #popupTemplate> <ul kendoMenuList [appendTo]="appendTo" [attr.id]="childId" [animate]="animate" [rtl]="rtl" [vertical]="vertical" [size]="size" [openOnClick]="openOnClick" [items]="children" [level]="level + 1" [index]="index" [itemTemplate]="itemTemplate" [itemLinkTemplate]="itemLinkTemplate" [ngClass]="menuListClasses" role="menu"> </ul> </ng-template> <ng-template #itemcontent> <kendo-icon-wrapper *ngIf="item.icon || item.svgIcon" [name]="item.icon" [svgIcon]="item.svgIcon" role="presentation"></kendo-icon-wrapper> <ng-container *ngIf="!template"> <span class="k-menu-link-text">{{ item.text }}</span> </ng-container> <ng-template *ngIf="template" [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ item: item, index: index }"> </ng-template> <kendo-icon-wrapper *ngIf="hasContent" [name]="fontIcon(horizontal, rtl)" [svgIcon]="SVGIcon(horizontal, rtl)" class="k-menu-expand-arrow" aria-hidden="true"></kendo-icon-wrapper> </ng-template> `, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: ListComponent, selector: "[kendoMenuList]", inputs: ["appendTo", "items", "level", "index", "animate", "size", "vertical", "rtl", "openOnClick", "itemTemplate", "itemLinkTemplate"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: IconWrapperComponent, selector: "kendo-icon-wrapper", inputs: ["name", "svgIcon", "innerCssClass", "customFontClass", "size"], exportAs: ["kendoIconWrapper"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ItemComponent, decorators: [{ type: Component, args: [{ providers: [PopupService, { provide: POPUP_CONTAINER, useFactory: bodyFactory }], selector: '[kendoMenuItem]', template: ` <span *ngIf="!hasLink && !item.content && !linkTemplate" class="k-link k-menu-link" #link [class.k-active]="opened" role="presentation"> <ng-template [ngTemplateOutlet]="itemcontent"> </ng-template> </span> <a *ngIf="item.url && !linkTemplate" class="k-link k-menu-link" #link [attr.href]="item.url" [class.k-active]="opened" tabindex="-1" role="presentation"> <ng-template [ngTemplateOutlet]="itemcontent"> </ng-template> </a> <ng-template *ngIf="linkTemplate && !item.content" [ngTemplateOutlet]="linkTemplate" [ngTemplateOutletContext]="{ item: item, index: index }"> </ng-template> <div class="k-content" *ngIf="item.content" role="presentation"> <ng-template [ngTemplateOutlet]="item.content" [ngTemplateOutletContext]="{ item: item.owner, index: item.ownerIndex }"> </ng-template> </div> <ng-template #popupTemplate> <ul kendoMenuList [appendTo]="appendTo" [attr.id]="childId" [animate]="animate" [rtl]="rtl" [vertical]="vertical" [size]="size" [openOnClick]="openOnClick" [items]="children" [level]="level + 1" [index]="index" [itemTemplate]="itemTemplate" [itemLinkTemplate]="itemLinkTemplate" [ngClass]="menuListClasses" role="menu"> </ul> </ng-template> <ng-template #itemcontent> <kendo-icon-wrapper *ngIf="item.icon || item.svgIcon" [name]="item.icon" [svgIcon]="item.svgIcon" role="presentation"></kendo-icon-wrapper> <ng-container *ngIf="!template"> <span class="k-menu-link-text">{{ item.text }}</span> </ng-container> <ng-template *ngIf="template" [ngTemplateOutlet]="template" [ngTemplateOutletContext]="{ item: item, index: index }"> </ng-template> <kendo-icon-wrapper *ngIf="hasContent" [name]="fontIcon(horizontal, rtl)" [svgIcon]="SVGIcon(horizontal, rtl)" class="k-menu-expand-arrow" aria-hidden="true"></kendo-icon-wrapper> </ng-template> `, standalone: true, imports: [NgIf, NgTemplateOutlet, ListComponent, NgClass, IconWrapperComponent] }] }], ctorParameters: function () { return [{ type: i1.ItemsService }, { type: i4.NavigationService }, { type: i0.ChangeDetectorRef }, { type: i0.Renderer2 }, { type: i5.PopupService }, { type: i0.ElementRef }, { type: i6.ContextMenuService, decorators: [{ type: Optional }] }]; }, propDecorators: { appendTo: [{ type: Input }], item: [{ type: Input }], level: [{ type: Input }], index: [{ type: Input }], siblingIndex: [{ type: Input }], animate: [{ type: Input }], size: [{ type: Input }], vertical: [{ type: Input }], rtl: [{ type: Input }], openOnClick: [{ type: Input }], itemTemplate: [{ type: Input }], itemLinkTemplate: [{ type: Input }], link: [{ type: ViewChild, args: ['link', { static: false }] }], popupTemplate: [{ type: ViewChild, args: ['popupTemplate', { static: true }] }], disabled: [{ type: HostBinding, args: ['attr.aria-disabled'] }], hasPopup: [{ type: HostBinding, args: ['attr.aria-haspopup'] }], label: [{ type: HostBinding, args: ['attr.aria-label'] }] } });