Skip to content

feat(cdk-experimental/menu): add ability to open menus from a standalone trigger #20363

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 28, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/cdk-experimental/menu/context-menu.spec.ts
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Loading