UNPKG

@progress/kendo-angular-menu

Version:

Kendo UI Angular Menu component

523 lines (522 loc) 19.8 kB
/**----------------------------------------------------------------------------------------- * Copyright © 2025 Progress Software Corporation. All rights reserved. * Licensed under commercial license. See LICENSE.md in the project root for more information *-------------------------------------------------------------------------------------------*/ import { Component, Input, ContentChild, ViewChild, EventEmitter, Output, NgZone, Renderer2, TemplateRef, ViewContainerRef, forwardRef } from '@angular/core'; import { LocalizationService, L10N_PREFIX } from '@progress/kendo-angular-l10n'; import { PopupService, POPUP_CONTAINER } from '@progress/kendo-angular-popup'; import { hasObservers, Keys, isDocumentAvailable } from '@progress/kendo-angular-common'; import { MenuBase } from '../menu-base'; import { ContextMenuPopupEvent } from './context-menu-popup-event'; import { ContextMenuService } from './context-menu.service'; import { ContextMenuItemsService } from './context-menu-items.service'; import { ContextMenuTemplateDirective } from './context-menu-template.directive'; import { closest, findInContainer, isFocusable, hasClass } from '../dom-queries'; import { defined } from '../utils'; import { ItemsService } from '../services/items.service'; import { ContextMenuTargetContainerDirective } from './context-menu-target-container.directive'; import { TARGET_CLASS } from './context-menu-target.directive'; import { bodyFactory } from '../utils'; import { MenuComponent } from '../menu.component'; import * as i0 from "@angular/core"; import * as i1 from "@progress/kendo-angular-popup"; import * as i2 from "./context-menu.service"; const CONTEXT_MENU = 'contextmenu'; const DEFAULT_ANCHOR_ALIGN = { horizontal: 'left', vertical: 'bottom' }; const DEFAULT_POPUP_ALIGN = { horizontal: 'left', vertical: 'top' }; const DEFAULT_COLLISION = { horizontal: 'fit', vertical: 'flip' }; const preventDefault = e => e.preventDefault(); /** * Represents the [Kendo UI ContextMenu component for Angular]({% slug overview_contextmenu %}). * * @example * ```ts * _@Component({ * selector: 'my-app', * template: ` * <div #target> * Right-click to open Context menu</p> * </div> * <kendo-contextmenu [target]="target" [items]="items"> </kendo-contextmenu> * ` * }) * class AppComponent { * public items: any[] = [{ text: 'item1', items: [{ text: 'item1.1' }] }, { text: 'item2', disabled: true }]; * } * ``` */ export class ContextMenuComponent extends MenuBase { popupService; service; ngZone; renderer; /** * Specifies the event on which the ContextMenu will open ([see example]({% slug showon_contextmenu %})). * Accepts the name of a native DOM event. For example, `click`, `dblclick`, `mouseover`, etc. * * @default 'contextmenu' */ showOn = CONTEXT_MENU; /** * Specifies the element for which the ContextMenu will open ([see example]({% slug target_contextmenu %}#toc-configuration)). */ target; /** * Specifies a CSS selector which filters the elements in the target for which the ContextMenu will open * ([see example](slug:target_contextmenu#toc-changing-items-for-specific-targets)). */ filter; /** * Specifies if the ContextMenu will be aligned to the target or to the `filter` element (if specified). * * @default false */ alignToAnchor = false; /** * Specifies if the Menu will be vertically rendered ([see example]({% slug orientation_contextmenu %})). * * @default true */ vertical = true; /** * Specifies the popup animation. * * @default true */ popupAnimate; /** * Specifies the pivot point of the popup. * * @default { horizontal: 'left', vertical: 'top' } */ popupAlign; /** * Specifies the pivot point of the anchor. Applicable if `alignToAnchor` is `true`. * * @default { horizontal: 'left', vertical: 'bottom' } */ anchorAlign; /** * Configures the collision behavior of the popup. * * @default { horizontal: 'fit', vertical: 'flip' } */ collision; /** * Defines the container to which the popups will be appended. */ appendTo; /** * Sets the value for the [`aria-label`](https://www.w3.org/TR/wai-aria-1.1/#aria-label) attribute of the ContextMenu. */ ariaLabel; /** * Fires when the Menu is opened ([see example](slug:events_contextmenu)). */ popupOpen = new EventEmitter(); /** * Fires when the Menu is closed ([see example](slug:events_contextmenu)). */ popupClose = new EventEmitter(); /** * Fires when a Menu item is selected ([see example](slug:events_contextmenu)). */ select = new EventEmitter(); /** * Fires when a Menu item is opened ([see example](slug:events_contextmenu)). */ open = new EventEmitter(); /** * Fires when a Menu item is closed ([see example](slug:events_contextmenu)). */ close = new EventEmitter(); /** * @hidden */ contentTemplate; /** * @hidden */ defaultContentTemplate; closeSubscription; showSubscription; keydownSubscription; popupSubscriptions; popupRef; currentTarget; directiveTarget; activeTarget; constructor(popupService, service, ngZone, renderer) { super(); this.popupService = popupService; this.service = service; this.ngZone = ngZone; this.renderer = renderer; this.service.owner = this; this.popupKeyDownHandler = this.popupKeyDownHandler.bind(this); } /** * Hides the ContextMenu. */ hide() { this.removePopup(); } /** * Shows the ContextMenu for the specified target. * * @param target - The offset or the target element for which the ContextMenu will open. */ show(target) { if (!target) { return; } const showTarget = target; this.removePopup(); if (defined(showTarget.left) && defined(showTarget.top)) { this.createPopup({ offset: showTarget }); } else { this.currentTarget = showTarget.nativeElement || showTarget; this.createPopup({ anchor: this.currentTarget }); } } ngOnChanges(changes) { if (changes.target || changes.showOn) { this.bindShowHandler(); } } ngOnInit() { this.ngZone.runOutsideAngular(() => { const closeClickSubscription = this.renderer.listen('document', 'mousedown', (e) => { if (this.popupRef && !closest(e.target, node => node === this.popupRef.popupElement) && this.service.leaveMenu(e)) { this.closePopup(e); } }); const closeBlurSubscription = this.renderer.listen('window', 'blur', (e) => { if (this.popupRef) { this.closePopup(e); } }); this.closeSubscription = () => { closeClickSubscription(); closeBlurSubscription(); }; }); } ngOnDestroy() { if (this.closeSubscription) { this.closeSubscription(); this.closeSubscription = null; } this.unbindShowHandler(); this.removePopup(); } /** * @hidden */ emitMenuEvent(name, args) { args.target = this.currentTarget; args.sender = this; this[name].emit(args); if (name === 'select' && !args.hasContent) { this.closeAndFocus(args.originalEvent); } } bindShowHandler() { this.unbindShowHandler(); this.ngZone.runOutsideAngular(() => { const element = this.targetElement(); if (!element) { return; } const eventName = this.showOn || CONTEXT_MENU; this.showSubscription = this.renderer.listen(element, this.showOn || CONTEXT_MENU, (e) => { this.showContextMenu(e, element); }); if (eventName === CONTEXT_MENU) { this.keydownSubscription = this.renderer.listen(element, 'keydown', (e) => { if (e.shiftKey && e.code === Keys.F10) { this.showContextMenu(e, element); } }); } }); } showContextMenu(e, element) { const filter = this.targetFilter(); let currentTarget = element; if (filter) { currentTarget = findInContainer(e.target, filter, element); if (currentTarget && currentTarget !== e.target && isFocusable(e.target)) { return; } if (currentTarget && this.directiveTarget) { currentTarget = this.target.targetService.find(currentTarget); } } if (!currentTarget) { this.closePopup(e); return; } this.ngZone.run(() => { if (!this.closePopup(e)) { this.currentTarget = currentTarget; this.openPopup(e); } }); } unbindShowHandler() { if (this.showSubscription) { this.showSubscription(); this.showSubscription = null; } if (this.keydownSubscription) { this.keydownSubscription(); this.keydownSubscription = null; } } targetElement() { if (!isDocumentAvailable()) { return; } this.directiveTarget = false; let target = this.target; if (typeof target === 'string') { target = document.querySelector(target); // maybe querySelectorAll? } else if (target && target.nativeElement) { target = target.nativeElement; } else if (target instanceof ContextMenuTargetContainerDirective) { target = target.element; this.directiveTarget = true; } return target; } targetFilter() { if (this.directiveTarget) { return `.${TARGET_CLASS}`; } return this.filter; } closePopup(e) { if (!this.popupRef) { return; } return this.popupAction('popupClose', e, () => { this.removePopup(); }); } removePopup() { if (this.popupRef) { this.popupRef.close(); this.popupRef = null; this.currentTarget = null; } if (this.popupSubscriptions) { this.popupSubscriptions(); this.popupSubscriptions = null; } } openPopup(e) { this.popupAction('popupOpen', e, () => { e.preventDefault(); let anchor, offset; if (this.alignToAnchor || e.type === 'keydown') { anchor = this.currentTargetElement; } else { offset = { left: e.pageX, top: e.pageY }; } this.createPopup({ anchor, offset }); }); } createPopup(options) { this.popupRef = this.popupService.open(Object.assign({ animate: defined(this.popupAnimate) ? this.popupAnimate : true, appendTo: this.appendTo, collision: this.collision || DEFAULT_COLLISION, popupAlign: this.popupAlign || DEFAULT_POPUP_ALIGN, anchorAlign: this.anchorAlign || DEFAULT_ANCHOR_ALIGN, content: this.contentTemplate ? this.contentTemplate.templateRef : this.defaultContentTemplate, popupClass: 'k-menu-popup', positionMode: 'absolute' }, options)); const element = this.popupRef.popupElement; this.renderer.addClass(element, 'k-context-menu-popup'); this.renderer.setAttribute(element, 'tabindex', '-1'); this.renderer.setStyle(element, 'outline', '0'); //possibly move to styles if (this.ariaLabel) { this.renderer.setAttribute(element, 'aria-label', this.ariaLabel); } this.activeTarget = this.currentTargetElement === document.activeElement; this.ngZone.runOutsideAngular(() => { const unbindKeyDown = this.renderer.listen(element, 'keydown', this.popupKeyDownHandler); const unbindContextmenu = this.renderer.listen(element, 'contextmenu', preventDefault); this.popupSubscriptions = () => { unbindKeyDown(); unbindContextmenu(); }; }); element.focus(); } closeAndFocus(e) { const currentTarget = this.currentTargetElement; if (!this.closePopup(e) && this.activeTarget) { currentTarget.focus(); } } popupKeyDownHandler(e) { const element = this.popupRef.popupElement; if (e.code === Keys.Escape && (hasClass(e.target, 'k-menu-item') || e.target === element)) { this.closeAndFocus(e); } else if (e.target === element) { this.service.keydown.emit(e); } } popupAction(name, originalEvent, callback) { const emitter = this[name]; let prevented = false; if (hasObservers(emitter)) { this.ngZone.run(() => { const args = new ContextMenuPopupEvent({ originalEvent: originalEvent, sender: this, target: this.currentTarget }); emitter.emit(args); if (!args.isDefaultPrevented()) { callback(); } prevented = args.isDefaultPrevented(); }); } else { callback(); } return prevented; } get currentTargetElement() { return this.directiveTarget && this.currentTarget ? this.currentTarget.element : this.currentTarget; } static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ContextMenuComponent, deps: [{ token: i1.PopupService }, { token: i2.ContextMenuService }, { token: i0.NgZone }, { token: i0.Renderer2 }], target: i0.ɵɵFactoryTarget.Component }); static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "16.2.12", type: ContextMenuComponent, isStandalone: true, selector: "kendo-contextmenu", inputs: { showOn: "showOn", target: "target", filter: "filter", alignToAnchor: "alignToAnchor", vertical: "vertical", popupAnimate: "popupAnimate", popupAlign: "popupAlign", anchorAlign: "anchorAlign", collision: "collision", appendTo: "appendTo", ariaLabel: "ariaLabel" }, outputs: { popupOpen: "popupOpen", popupClose: "popupClose", select: "select", open: "open", close: "close" }, providers: [ ContextMenuService, LocalizationService, { provide: L10N_PREFIX, useValue: 'kendo.contextmenu' }, { provide: ItemsService, useClass: ContextMenuItemsService }, { provide: MenuBase, useExisting: forwardRef(() => ContextMenuComponent) }, PopupService, { provide: POPUP_CONTAINER, useFactory: bodyFactory } ], queries: [{ propertyName: "contentTemplate", first: true, predicate: ContextMenuTemplateDirective, descendants: true }], viewQueries: [{ propertyName: "defaultContentTemplate", first: true, predicate: ["default"], descendants: true }], exportAs: ["kendoContextMenu"], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: ` <ng-template #default> <kendo-menu [items]="rootItems" [appendTo]="appendTo" [size]="size" ariaRole="menu" [vertical]="vertical" [openOnClick]="openOnClick" [hoverDelay]="hoverDelay" [animate]="animate" [menuItemTemplate]="itemTemplate.first?.templateRef" [menuItemLinkTemplate]="itemLinkTemplate.first?.templateRef" ></kendo-menu> </ng-template> `, isInline: true, dependencies: [{ kind: "component", type: MenuComponent, selector: "kendo-menu", inputs: ["appendTo", "menuItemTemplate", "ariaRole", "menuItemLinkTemplate"], outputs: ["select", "open", "close"], exportAs: ["kendoMenu"] }] }); } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "16.2.12", ngImport: i0, type: ContextMenuComponent, decorators: [{ type: Component, args: [{ exportAs: 'kendoContextMenu', providers: [ ContextMenuService, LocalizationService, { provide: L10N_PREFIX, useValue: 'kendo.contextmenu' }, { provide: ItemsService, useClass: ContextMenuItemsService }, { provide: MenuBase, useExisting: forwardRef(() => ContextMenuComponent) }, PopupService, { provide: POPUP_CONTAINER, useFactory: bodyFactory } ], selector: 'kendo-contextmenu', template: ` <ng-template #default> <kendo-menu [items]="rootItems" [appendTo]="appendTo" [size]="size" ariaRole="menu" [vertical]="vertical" [openOnClick]="openOnClick" [hoverDelay]="hoverDelay" [animate]="animate" [menuItemTemplate]="itemTemplate.first?.templateRef" [menuItemLinkTemplate]="itemLinkTemplate.first?.templateRef" ></kendo-menu> </ng-template> `, standalone: true, imports: [MenuComponent] }] }], ctorParameters: function () { return [{ type: i1.PopupService }, { type: i2.ContextMenuService }, { type: i0.NgZone }, { type: i0.Renderer2 }]; }, propDecorators: { showOn: [{ type: Input }], target: [{ type: Input }], filter: [{ type: Input }], alignToAnchor: [{ type: Input }], vertical: [{ type: Input }], popupAnimate: [{ type: Input }], popupAlign: [{ type: Input }], anchorAlign: [{ type: Input }], collision: [{ type: Input }], appendTo: [{ type: Input }], ariaLabel: [{ type: Input }], popupOpen: [{ type: Output }], popupClose: [{ type: Output }], select: [{ type: Output }], open: [{ type: Output }], close: [{ type: Output }], contentTemplate: [{ type: ContentChild, args: [ContextMenuTemplateDirective, { static: false }] }], defaultContentTemplate: [{ type: ViewChild, args: ['default', { static: false }] }] } });