@progress/kendo-angular-menu
Version:
Kendo UI Angular Menu component
523 lines (522 loc) • 19.8 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 { 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 }]
}] } });