Skip to content

Commit

Permalink
feat(cdk-experimental/menu): add ability to open menus from a standal…
Browse files Browse the repository at this point in the history
…one trigger (#20363)

Adds the ability to open a menu from a menu trigger placed outside of a menu or menubar.
  • Loading branch information
andy9775 committed Aug 28, 2020
1 parent abfb7a2 commit de98466
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 146 deletions.
6 changes: 4 additions & 2 deletions src/cdk-experimental/menu/context-menu.spec.ts
@@ -1,4 +1,4 @@
import {Component, ViewChild, ElementRef, Type} from '@angular/core';
import {Component, ViewChild, ElementRef, Type, ViewChildren, QueryList} from '@angular/core';
import {CdkMenuModule} from './menu-module';
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {CdkMenu} from './menu';
Expand Down Expand Up @@ -84,7 +84,7 @@ describe('CdkContextMenuTrigger', () => {
openContextMenu();
openContextMenu();

const menus = fixture.debugElement.queryAll(By.directive(CdkMenu));
const menus = fixture.componentInstance.menus;
expect(menus.length)
.withContext('two context menu triggers should result in a single context menu')
.toBe(1);
Expand Down Expand Up @@ -392,6 +392,8 @@ class SimpleContextMenu {
@ViewChild(CdkContextMenuTrigger, {read: ElementRef}) trigger: ElementRef<HTMLElement>;
@ViewChild(CdkMenu) menu?: CdkMenu;
@ViewChild(CdkMenu, {read: ElementRef}) nativeMenu?: ElementRef<HTMLElement>;

@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
}

@Component({
Expand Down
76 changes: 23 additions & 53 deletions src/cdk-experimental/menu/context-menu.ts
Expand Up @@ -18,7 +18,6 @@ import {
Injectable,
InjectionToken,
} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {Directionality} from '@angular/cdk/bidi';
import {
OverlayRef,
Expand All @@ -29,26 +28,12 @@ import {
} 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 {Subject, merge} from 'rxjs';
import {takeUntil} from 'rxjs/operators';
import {CdkMenuPanel} from './menu-panel';
import {MenuStack, MenuStackItem} from './menu-stack';
import {throwExistingMenuStackError} from './menu-errors';

/**
* 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.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline')) {
return true;
}
target = target.parentElement;
}
return false;
}
import {isClickInsideMenuOverlay} from './menu-item-trigger';

/** Tracks the last open context menu trigger across the entire application. */
@Injectable({providedIn: 'root'})
Expand Down Expand Up @@ -146,25 +131,19 @@ export class CdkContextMenuTrigger implements OnDestroy {
/** Emits when the element is destroyed. */
private readonly _destroyed: Subject<void> = 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();

/** Emits when the outside pointer events listener on the overlay should be stopped. */
private readonly _stopOutsideClicksListener = merge(this.closed, this._destroyed);

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();
}

Expand Down Expand Up @@ -195,7 +174,7 @@ export class CdkContextMenuTrigger implements OnDestroy {
}

this._overlayRef.attach(this._getMenuContent());
this._setCloseListener();
this._subscribeToOutsideClicks();
}
}

Expand Down Expand Up @@ -290,32 +269,6 @@ export class CdkContextMenuTrigger implements OnDestroy {
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) => {
Expand All @@ -326,6 +279,23 @@ export class CdkContextMenuTrigger implements OnDestroy {
});
}

/**
* Subscribe to the overlays outside pointer events stream and handle closing out the stack if a
* click occurs outside the menus.
*/
private _subscribeToOutsideClicks() {
if (this._overlayRef) {
this._overlayRef
.outsidePointerEvents()
.pipe(takeUntil(this._stopOutsideClicksListener))
.subscribe(event => {
if (!isClickInsideMenuOverlay(event.target as Element)) {
this._menuStack.closeAll();
}
});
}
}

ngOnDestroy() {
this._destroyOverlay();
this._resetPanelMenuStack();
Expand Down
18 changes: 3 additions & 15 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Expand Up @@ -829,10 +829,8 @@ describe('MenuBar', () => {
openMenu();
expect(popoutMenus.length).toBe(1);

dispatchMouseEvent(
fixture.debugElement.query(By.css('#container')).nativeElement,
'mousedown'
);

fixture.debugElement.query(By.css('#container')).nativeElement.click();
detectChanges();

expect(popoutMenus.length).toBe(0);
Expand All @@ -859,16 +857,6 @@ describe('MenuBar', () => {
expect(popoutMenus.length).toBe(1);
});

it('should not close open menus when clicking on a menu bar', () => {
openMenu();
expect(popoutMenus.length).toBe(1);

fixture.debugElement.query(By.directive(CdkMenuBar)).nativeElement.click();
detectChanges();

expect(popoutMenus.length).toBe(1);
});

it('should not close when clicking on a CdkMenuItemCheckbox element', () => {
openMenu();
expect(popoutMenus.length).toBe(1);
Expand All @@ -894,7 +882,7 @@ describe('MenuBar', () => {
it('should close the open menu when clicking on an inline menu item', () => {
openMenu();

dispatchMouseEvent(nativeInlineMenuItem, 'mousedown');
nativeInlineMenuItem.click();
detectChanges();

expect(popoutMenus.length).toBe(0);
Expand Down
33 changes: 0 additions & 33 deletions src/cdk-experimental/menu/menu-bar.ts
Expand Up @@ -28,18 +28,6 @@ import {CdkMenuItem} from './menu-item';
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
import {getItemPointerEntries} from './item-pointer-entries';

/**
* Whether the element is a menu bar or a popup menu.
* @param target the element to check.
* @return true if the given element is part of the menu module.
*/
function isMenuElement(target: Element) {
return (
target.classList.contains('cdk-menu-bar') ||
(target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline'))
);
}

/**
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
Expand Down Expand Up @@ -259,27 +247,6 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
return this.orientation === 'horizontal';
}

// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
// can move this back into `host`.
// tslint:disable:no-host-decorator-in-concrete
@HostListener('document:mousedown', ['$event'])
/** Close any open submenu if there was a click event which occurred outside the menu stack. */
_closeOnBackgroundClick(event: MouseEvent) {
if (this._hasOpenSubmenu()) {
// get target from composed path to account for shadow dom
let target = event.composedPath ? event.composedPath()[0] : event.target;
while (target instanceof Element) {
if (isMenuElement(target)) {
return;
}
target = target.parentElement;
}

this._menuStack.closeAll();
}
}

/**
* Subscribe to the menu trigger's open events in order to track the trigger which opened the menu
* and stop tracking it when the menu is closed.
Expand Down
4 changes: 2 additions & 2 deletions src/cdk-experimental/menu/menu-item-radio.ts
Expand Up @@ -41,14 +41,14 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy
private readonly _selectionDispatcher: UniqueSelectionDispatcher,
element: ElementRef<HTMLElement>,
ngZone: NgZone,
@Inject(CDK_MENU) parentMenu: Menu,
@Optional() @Inject(CDK_MENU) parentMenu?: Menu,
@Optional() dir?: Directionality,
/** Reference to the CdkMenuItemTrigger directive if one is added to the same element */
// `CdkMenuItemRadio` is commonly used in combination with a `CdkMenuItemTrigger`.
// tslint:disable-next-line: lightweight-tokens
@Self() @Optional() menuTrigger?: CdkMenuItemTrigger
) {
super(element, parentMenu, ngZone, dir, menuTrigger);
super(element, ngZone, parentMenu, dir, menuTrigger);

this._registerDispatcherListener();
}
Expand Down

0 comments on commit de98466

Please sign in to comment.