diff --git a/src/cdk-experimental/menu/context-menu.spec.ts b/src/cdk-experimental/menu/context-menu.spec.ts new file mode 100644 index 000000000000..8652df530127 --- /dev/null +++ b/src/cdk-experimental/menu/context-menu.spec.ts @@ -0,0 +1,314 @@ +import {Component, ViewChild, ElementRef} from '@angular/core'; +import {CdkMenuModule} from './menu-module'; +import {TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {CdkMenu} from './menu'; +import {CdkContextMenuTrigger} from './context-menu'; +import {dispatchMouseEvent} from '@angular/cdk/testing/private'; +import {By} from '@angular/platform-browser'; +import {CdkMenuItem} from './menu-item'; +import {CdkMenuItemTrigger} from './menu-item-trigger'; + +describe('CdkContextMenuTrigger', () => { + describe('with simple context menu trigger', () => { + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [SimpleContextMenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleContextMenu); + fixture.detectChanges(); + }); + + /** Get the menu opened by the context menu trigger. */ + function getContextMenu() { + return fixture.componentInstance.menu; + } + + /** Return a reference to the context menu native element. */ + function getNativeContextMenu() { + return fixture.componentInstance.nativeMenu?.nativeElement; + } + + /** Get the context in which the context menu should trigger. */ + function getMenuContext() { + return fixture.componentInstance.trigger.nativeElement; + } + + /** Open up the context menu and run change detection. */ + function openContextMenu() { + // right click triggers a context menu event + dispatchMouseEvent(getMenuContext(), 'contextmenu'); + fixture.detectChanges(); + } + + it('should display context menu on right click inside of context component', () => { + expect(getContextMenu()).not.toBeDefined(); + openContextMenu(); + expect(getContextMenu()).toBeDefined(); + }); + + it('should close out the context menu when clicking in the context', () => { + openContextMenu(); + + getMenuContext().click(); + fixture.detectChanges(); + + expect(getContextMenu()).not.toBeDefined(); + }); + + it('should close out the context menu when clicking on element outside of the context', () => { + openContextMenu(); + + fixture.nativeElement.querySelector('#other').click(); + fixture.detectChanges(); + + expect(getContextMenu()).not.toBeDefined(); + }); + + it('should close out the context menu when clicking a menu item', () => { + openContextMenu(); + + fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem).trigger(); + fixture.detectChanges(); + + expect(getContextMenu()).not.toBeDefined(); + }); + + it('should re-open the same menu when right clicking twice in the context', () => { + openContextMenu(); + openContextMenu(); + + const menus = fixture.debugElement.queryAll(By.directive(CdkMenu)); + expect(menus.length) + .withContext('two context menu triggers should result in a single context menu') + .toBe(1); + }); + + it('should retain the context menu on right click inside the open menu', () => { + openContextMenu(); + + dispatchMouseEvent(getNativeContextMenu()!, 'contextmenu'); + fixture.detectChanges(); + + expect(getContextMenu()).toBeDefined(); + }); + }); + + describe('nested context menu triggers', () => { + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [NestedContextMenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(NestedContextMenu); + fixture.detectChanges(); + }); + + /** Get the cut context menu. */ + function getCutMenu() { + return fixture.componentInstance.cutMenu; + } + + /** Get the copy context menu. */ + function getCopyMenu() { + return fixture.componentInstance.copyMenu; + } + + /** Get the context in which the cut context menu should trigger. */ + function getCutMenuContext() { + return fixture.componentInstance.cutContext.nativeElement; + } + + /** Get the context in which the copy context menu should trigger. */ + function getCopyMenuContext() { + return fixture.componentInstance.copyContext.nativeElement; + } + + /** Open up the cut context menu and run change detection. */ + function openCutContextMenu() { + // right click triggers a context menu event + dispatchMouseEvent(getCutMenuContext(), 'contextmenu'); + fixture.detectChanges(); + } + + /** Open up the copy context menu and run change detection. */ + function openCopyContextMenu() { + // right click triggers a context menu event + dispatchMouseEvent(getCopyMenuContext(), 'contextmenu'); + fixture.detectChanges(); + } + + it('should open the cut context menu only when right clicked in its trigger context', () => { + openCutContextMenu(); + + expect(getCutMenu()).toBeDefined(); + expect(getCopyMenu()).not.toBeDefined(); + }); + + it('should open the nested copy context menu only when right clicked in nested context', () => { + openCopyContextMenu(); + + expect(getCopyMenu()).toBeDefined(); + expect(getCutMenu()).not.toBeDefined(); + }); + + it( + 'should open the parent context menu only when right clicked in nested context and nested' + + ' is disabled', + () => { + fixture.componentInstance.copyMenuDisabled = true; + fixture.detectChanges(); + openCopyContextMenu(); + + expect(getCopyMenu()).not.toBeDefined(); + expect(getCutMenu()).toBeDefined(); + } + ); + + it('should close nested context menu when parent is opened', () => { + openCopyContextMenu(); + + openCutContextMenu(); + + expect(getCopyMenu()).not.toBeDefined(); + expect(getCutMenu()).toBeDefined(); + }); + + it('should close the parent context menu when nested is open', () => { + openCutContextMenu(); + + openCopyContextMenu(); + + expect(getCopyMenu()).toBeDefined(); + expect(getCutMenu()).not.toBeDefined(); + }); + + it('should close nested context menu when clicking in parent', () => { + openCopyContextMenu(); + + getCutMenuContext().click(); + fixture.detectChanges(); + + expect(getCopyMenu()).not.toBeDefined(); + }); + + it('should close parent context menu when clicking in nested menu', () => { + openCutContextMenu(); + + getCopyMenuContext().click(); + fixture.detectChanges(); + + expect(getCutMenu()).not.toBeDefined(); + }); + }); + + describe('with context menu that has submenu', () => { + let fixture: ComponentFixture; + let instance: ContextMenuWithSubmenu; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [ContextMenuWithSubmenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ContextMenuWithSubmenu); + fixture.detectChanges(); + + instance = fixture.componentInstance; + }); + + it('should open context menu submenu without closing context menu', () => { + dispatchMouseEvent(instance.context.nativeElement, 'contextmenu'); + fixture.detectChanges(); + + instance.triggerNativeElement.nativeElement.click(); + fixture.detectChanges(); + + expect(instance.cutMenu).toBeDefined(); + expect(instance.copyMenu).toBeDefined(); + }); + }); +}); + +@Component({ + template: ` +
+
+ + +
+ +
+
+ `, +}) +class SimpleContextMenu { + @ViewChild(CdkContextMenuTrigger, {read: ElementRef}) trigger: ElementRef; + @ViewChild(CdkMenu) menu?: CdkMenu; + @ViewChild(CdkMenu, {read: ElementRef}) nativeMenu?: ElementRef; +} + +@Component({ + template: ` +
+
+
+ + +
+
+ + +
+
+ `, +}) +class NestedContextMenu { + @ViewChild('cut_trigger', {read: ElementRef}) cutContext: ElementRef; + @ViewChild('copy_trigger', {read: ElementRef}) copyContext: ElementRef; + + @ViewChild('cut_menu', {read: CdkMenu}) cutMenu: CdkMenu; + @ViewChild('copy_menu', {read: CdkMenu}) copyMenu: CdkMenu; + + copyMenuDisabled = false; +} + +@Component({ + template: ` +
+ + +
+ +
+
+ + +
+
+ `, +}) +class ContextMenuWithSubmenu { + @ViewChild(CdkContextMenuTrigger, {read: ElementRef}) context: ElementRef; + @ViewChild(CdkMenuItemTrigger, {read: ElementRef}) triggerNativeElement: ElementRef; + + @ViewChild('cut_menu', {read: CdkMenu}) cutMenu: CdkMenu; + @ViewChild('copy_menu', {read: CdkMenu}) copyMenu: CdkMenu; +} diff --git a/src/cdk-experimental/menu/context-menu.ts b/src/cdk-experimental/menu/context-menu.ts new file mode 100644 index 000000000000..48e2477f9437 --- /dev/null +++ b/src/cdk-experimental/menu/context-menu.ts @@ -0,0 +1,332 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + Input, + ViewContainerRef, + Output, + EventEmitter, + Optional, + OnDestroy, + Inject, + Injectable, + InjectionToken, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {Directionality} from '@angular/cdk/bidi'; +import { + OverlayRef, + Overlay, + OverlayConfig, + FlexibleConnectedPositionStrategy, + ConnectedPosition, +} from '@angular/cdk/overlay'; +import {TemplatePortal, Portal} from '@angular/cdk/portal'; +import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion'; +import {fromEvent, merge, Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {CdkMenuPanel} from './menu-panel'; +import {MenuStack, MenuStackItem} from './menu-stack'; + +/** + * Check if the given element is part of the cdk menu module or nested within a cdk menu element. + * @param target the element to check. + * @return true if the given element is part of the menu module or nested within a cdk menu element. + */ +function isWithinMenuElement(target: Element | null) { + while (target instanceof Element) { + if (target.className.indexOf('cdk-menu') !== -1) { + return true; + } + target = target.parentElement; + } + return false; +} + +/** Tracks the last open context menu trigger across the entire application. */ +@Injectable({providedIn: 'root'}) +export class ContextMenuTracker { + /** The last open context menu trigger. */ + private static _openContextMenuTrigger?: CdkContextMenuTrigger; + + /** + * Close the previous open context menu and set the given one as being open. + * @param trigger the trigger for the currently open Context Menu. + */ + update(trigger: CdkContextMenuTrigger) { + if (ContextMenuTracker._openContextMenuTrigger !== trigger) { + ContextMenuTracker._openContextMenuTrigger?.close(); + ContextMenuTracker._openContextMenuTrigger = trigger; + } + } +} + +/** Configuration options passed to the context menu. */ +export type ContextMenuOptions = { + /** The opened menus X coordinate offset from the triggering position. */ + offsetX: number; + + /** The opened menus Y coordinate offset from the triggering position. */ + offsetY: number; +}; + +/** Injection token for the ContextMenu options object. */ +export const CDK_CONTEXT_MENU_DEFAULT_OPTIONS = new InjectionToken( + 'cdk-context-menu-default-options' +); + +/** The coordinates of where the context menu should open. */ +export type ContextMenuCoordinates = {x: number; y: number}; + +/** + * A directive which when placed on some element opens a the Menu it is bound to when a user + * right-clicks within that element. It is aware of nested Context Menus and the lowest level + * non-disabled context menu will trigger. + */ +@Directive({ + selector: '[cdkContextMenuTriggerFor]', + exportAs: 'cdkContextMenuTriggerFor', + host: { + '(contextmenu)': '_openOnContextMenu($event)', + }, + providers: [ + // In cases where the first menu item in the context menu is a trigger the submenu opens on a + // hover event. Offsetting the opened context menu by 2px prevents this from occurring. + {provide: CDK_CONTEXT_MENU_DEFAULT_OPTIONS, useValue: {offsetX: 2, offsetY: 2}}, + ], +}) +export class CdkContextMenuTrigger implements OnDestroy { + /** Template reference variable to the menu to open on right click. */ + @Input('cdkContextMenuTriggerFor') + get menuPanel(): CdkMenuPanel { + return this._menuPanel; + } + set menuPanel(panel: CdkMenuPanel) { + this._menuPanel = panel; + + if (this._menuPanel) { + this._menuPanel._menuStack = this._menuStack; + } + } + /** Reference to the MenuPanel this trigger toggles. */ + private _menuPanel: CdkMenuPanel; + + /** Emits when the attached menu is requested to open. */ + @Output('cdkContextMenuOpened') readonly opened: EventEmitter = new EventEmitter(); + + /** Emits when the attached menu is requested to close. */ + @Output('cdkContextMenuClosed') readonly closed: EventEmitter = new EventEmitter(); + + /** Whether the context menu should be disabled. */ + @Input('cdkContextMenuDisabled') + get disabled() { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = coerceBooleanProperty(value); + } + private _disabled = false; + + /** A reference to the overlay which manages the triggered menu. */ + private _overlayRef: OverlayRef | null = null; + + /** The content of the menu panel opened by this trigger. */ + private _panelContent: TemplatePortal; + + /** Emits when the element is destroyed. */ + private readonly _destroyed: Subject = new Subject(); + + /** Reference to the document. */ + private readonly _document: Document; + + /** Emits when the document listener should stop. */ + private readonly _stopDocumentListener = merge(this.closed, this._destroyed); + + /** The menu stack for this trigger and its associated menus. */ + private readonly _menuStack = new MenuStack(); + + constructor( + protected readonly _viewContainerRef: ViewContainerRef, + private readonly _overlay: Overlay, + private readonly _contextMenuTracker: ContextMenuTracker, + @Inject(CDK_CONTEXT_MENU_DEFAULT_OPTIONS) private readonly _options: ContextMenuOptions, + @Inject(DOCUMENT) document: any, + @Optional() private readonly _directionality?: Directionality + ) { + this._document = document; + + this._setMenuStackListener(); + } + + /** + * Open the attached menu at the specified location. + * @param coordinates where to open the context menu + */ + open(coordinates: ContextMenuCoordinates) { + if (this.disabled) { + return; + } else if (this.isOpen()) { + // since we're moving this menu we need to close any submenus first otherwise they end up + // disconnected from this one. + this._menuStack.closeSubMenuOf(this._menuPanel._menu!); + + (this._overlayRef!.getConfig() + .positionStrategy as FlexibleConnectedPositionStrategy).setOrigin(coordinates); + this._overlayRef!.updatePosition(); + } else { + this.opened.next(); + + if (this._overlayRef) { + (this._overlayRef.getConfig() + .positionStrategy as FlexibleConnectedPositionStrategy).setOrigin(coordinates); + this._overlayRef.updatePosition(); + } else { + this._overlayRef = this._overlay.create(this._getOverlayConfig(coordinates)); + } + + this._overlayRef.attach(this._getMenuContent()); + this._setCloseListener(); + } + } + + /** Close the opened menu. */ + close() { + this._menuStack.closeAll(); + } + + /** + * Open the context menu and close any previously open menus. + * @param event the mouse event which opens the context menu. + */ + _openOnContextMenu(event: MouseEvent) { + if (!this.disabled) { + // Prevent the native context menu from opening because we're opening a custom one. + event.preventDefault(); + + // Stop event propagation to ensure that only the closest enabled context menu opens. + // Otherwise, any context menus attached to containing elements would *also* open, + // resulting in multiple stacked context menus being displayed. + event.stopPropagation(); + + this._contextMenuTracker.update(this); + this.open({x: event.clientX, y: event.clientY}); + } + } + + /** Whether the attached menu is open. */ + isOpen() { + return !!this._overlayRef?.hasAttached(); + } + + /** + * Get the configuration object used to create the overlay. + * @param coordinates the location to place the opened menu + */ + private _getOverlayConfig(coordinates: ContextMenuCoordinates) { + return new OverlayConfig({ + positionStrategy: this._getOverlayPositionStrategy(coordinates), + scrollStrategy: this._overlay.scrollStrategies.block(), + direction: this._directionality, + }); + } + + /** + * Build the position strategy for the overlay which specifies where to place the menu. + * @param coordinates the location to place the opened menu + */ + private _getOverlayPositionStrategy( + coordinates: ContextMenuCoordinates + ): FlexibleConnectedPositionStrategy { + return this._overlay + .position() + .flexibleConnectedTo(coordinates) + .withDefaultOffsetX(this._options.offsetX) + .withDefaultOffsetY(this._options.offsetY) + .withPositions(this._getOverlayPositions()); + } + + /** + * Determine and return where to position the opened menu relative to the mouse location. + */ + private _getOverlayPositions(): ConnectedPosition[] { + // TODO: this should be configurable through the injected context menu options + return [ + {originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top'}, + {originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top'}, + {originX: 'end', originY: 'bottom', overlayX: 'start', overlayY: 'bottom'}, + {originX: 'start', originY: 'bottom', overlayX: 'end', overlayY: 'bottom'}, + ]; + } + + /** + * Get the portal to be attached to the overlay which contains the menu. Allows for the menu + * content to change dynamically and be reflected in the application. + */ + private _getMenuContent(): Portal { + const hasMenuContentChanged = this.menuPanel._templateRef !== this._panelContent?.templateRef; + if (this.menuPanel && (!this._panelContent || hasMenuContentChanged)) { + this._panelContent = new TemplatePortal(this.menuPanel._templateRef, this._viewContainerRef); + } + + return this._panelContent; + } + + /** + * Subscribe to the document click and context menu events and close out the menu when emitted. + */ + private _setCloseListener() { + merge(fromEvent(this._document, 'click'), fromEvent(this._document, 'contextmenu')) + .pipe(takeUntil(this._stopDocumentListener)) + .subscribe(event => { + const target = event.composedPath ? event.composedPath()[0] : event.target; + // stop the default context menu from appearing if user right-clicked somewhere outside of + // any context menu directive or if a user right-clicked inside of the opened menu and just + // close it. + if (event.type === 'contextmenu') { + if (target instanceof Element && isWithinMenuElement(target)) { + // Prevent the native context menu from opening within any open context menu or submenu + event.preventDefault(); + } else { + this.close(); + } + } else { + if (target instanceof Element && !isWithinMenuElement(target)) { + this.close(); + } + } + }); + } + + /** Subscribe to the menu stack close events and close this menu when requested. */ + private _setMenuStackListener() { + this._menuStack.closed.pipe(takeUntil(this._destroyed)).subscribe((item: MenuStackItem) => { + if (item === this._menuPanel._menu && this.isOpen()) { + this.closed.next(); + this._overlayRef!.detach(); + } + }); + } + + ngOnDestroy() { + this._destroyOverlay(); + + this._destroyed.next(); + this._destroyed.complete(); + } + + /** Destroy and unset the overlay reference it if exists. */ + private _destroyOverlay() { + if (this._overlayRef) { + this._overlayRef.dispose(); + this._overlayRef = null; + } + } + + static ngAcceptInputType_disabled: BooleanInput; +} diff --git a/src/cdk-experimental/menu/menu-module.ts b/src/cdk-experimental/menu/menu-module.ts index 386c7d566ded..9b8810d76e7d 100644 --- a/src/cdk-experimental/menu/menu-module.ts +++ b/src/cdk-experimental/menu/menu-module.ts @@ -16,6 +16,7 @@ import {CdkMenuGroup} from './menu-group'; import {CdkMenuItemRadio} from './menu-item-radio'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {CdkContextMenuTrigger} from './context-menu'; const EXPORTED_DECLARATIONS = [ CdkMenuBar, @@ -26,6 +27,7 @@ const EXPORTED_DECLARATIONS = [ CdkMenuItemCheckbox, CdkMenuItemTrigger, CdkMenuGroup, + CdkContextMenuTrigger, ]; @NgModule({ imports: [OverlayModule], diff --git a/src/cdk-experimental/menu/public-api.ts b/src/cdk-experimental/menu/public-api.ts index 59c8e5f472a6..0ff4b86879d5 100644 --- a/src/cdk-experimental/menu/public-api.ts +++ b/src/cdk-experimental/menu/public-api.ts @@ -15,6 +15,7 @@ export * from './menu-item-radio'; export * from './menu-item-trigger'; export * from './menu-panel'; export * from './menu-group'; +export * from './context-menu'; export * from './menu-stack'; export {CDK_MENU} from './menu-interface';