Skip to content

Commit de98466

Browse files
authored
feat(cdk-experimental/menu): add ability to open menus from a standalone trigger (#20363)
Adds the ability to open a menu from a menu trigger placed outside of a menu or menubar.
1 parent abfb7a2 commit de98466

8 files changed

+283
-146
lines changed

src/cdk-experimental/menu/context-menu.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component, ViewChild, ElementRef, Type} from '@angular/core';
1+
import {Component, ViewChild, ElementRef, Type, ViewChildren, QueryList} from '@angular/core';
22
import {CdkMenuModule} from './menu-module';
33
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
44
import {CdkMenu} from './menu';
@@ -84,7 +84,7 @@ describe('CdkContextMenuTrigger', () => {
8484
openContextMenu();
8585
openContextMenu();
8686

87-
const menus = fixture.debugElement.queryAll(By.directive(CdkMenu));
87+
const menus = fixture.componentInstance.menus;
8888
expect(menus.length)
8989
.withContext('two context menu triggers should result in a single context menu')
9090
.toBe(1);
@@ -392,6 +392,8 @@ class SimpleContextMenu {
392392
@ViewChild(CdkContextMenuTrigger, {read: ElementRef}) trigger: ElementRef<HTMLElement>;
393393
@ViewChild(CdkMenu) menu?: CdkMenu;
394394
@ViewChild(CdkMenu, {read: ElementRef}) nativeMenu?: ElementRef<HTMLElement>;
395+
396+
@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
395397
}
396398

397399
@Component({

src/cdk-experimental/menu/context-menu.ts

Lines changed: 23 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import {
1818
Injectable,
1919
InjectionToken,
2020
} from '@angular/core';
21-
import {DOCUMENT} from '@angular/common';
2221
import {Directionality} from '@angular/cdk/bidi';
2322
import {
2423
OverlayRef,
@@ -29,26 +28,12 @@ import {
2928
} from '@angular/cdk/overlay';
3029
import {TemplatePortal, Portal} from '@angular/cdk/portal';
3130
import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion';
32-
import {fromEvent, merge, Subject} from 'rxjs';
31+
import {Subject, merge} from 'rxjs';
3332
import {takeUntil} from 'rxjs/operators';
3433
import {CdkMenuPanel} from './menu-panel';
3534
import {MenuStack, MenuStackItem} from './menu-stack';
3635
import {throwExistingMenuStackError} from './menu-errors';
37-
38-
/**
39-
* Check if the given element is part of the cdk menu module or nested within a cdk menu element.
40-
* @param target the element to check.
41-
* @return true if the given element is part of the menu module or nested within a cdk menu element.
42-
*/
43-
function isWithinMenuElement(target: Element | null) {
44-
while (target instanceof Element) {
45-
if (target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline')) {
46-
return true;
47-
}
48-
target = target.parentElement;
49-
}
50-
return false;
51-
}
36+
import {isClickInsideMenuOverlay} from './menu-item-trigger';
5237

5338
/** Tracks the last open context menu trigger across the entire application. */
5439
@Injectable({providedIn: 'root'})
@@ -146,25 +131,19 @@ export class CdkContextMenuTrigger implements OnDestroy {
146131
/** Emits when the element is destroyed. */
147132
private readonly _destroyed: Subject<void> = new Subject();
148133

149-
/** Reference to the document. */
150-
private readonly _document: Document;
151-
152-
/** Emits when the document listener should stop. */
153-
private readonly _stopDocumentListener = merge(this.closed, this._destroyed);
154-
155134
/** The menu stack for this trigger and its associated menus. */
156135
private readonly _menuStack = new MenuStack();
157136

137+
/** Emits when the outside pointer events listener on the overlay should be stopped. */
138+
private readonly _stopOutsideClicksListener = merge(this.closed, this._destroyed);
139+
158140
constructor(
159141
protected readonly _viewContainerRef: ViewContainerRef,
160142
private readonly _overlay: Overlay,
161143
private readonly _contextMenuTracker: ContextMenuTracker,
162144
@Inject(CDK_CONTEXT_MENU_DEFAULT_OPTIONS) private readonly _options: ContextMenuOptions,
163-
@Inject(DOCUMENT) document: any,
164145
@Optional() private readonly _directionality?: Directionality
165146
) {
166-
this._document = document;
167-
168147
this._setMenuStackListener();
169148
}
170149

@@ -195,7 +174,7 @@ export class CdkContextMenuTrigger implements OnDestroy {
195174
}
196175

197176
this._overlayRef.attach(this._getMenuContent());
198-
this._setCloseListener();
177+
this._subscribeToOutsideClicks();
199178
}
200179
}
201180

@@ -290,32 +269,6 @@ export class CdkContextMenuTrigger implements OnDestroy {
290269
return this._panelContent;
291270
}
292271

293-
/**
294-
* Subscribe to the document click and context menu events and close out the menu when emitted.
295-
*/
296-
private _setCloseListener() {
297-
merge(fromEvent(this._document, 'click'), fromEvent(this._document, 'contextmenu'))
298-
.pipe(takeUntil(this._stopDocumentListener))
299-
.subscribe(event => {
300-
const target = event.composedPath ? event.composedPath()[0] : event.target;
301-
// stop the default context menu from appearing if user right-clicked somewhere outside of
302-
// any context menu directive or if a user right-clicked inside of the opened menu and just
303-
// close it.
304-
if (event.type === 'contextmenu') {
305-
if (target instanceof Element && isWithinMenuElement(target)) {
306-
// Prevent the native context menu from opening within any open context menu or submenu
307-
event.preventDefault();
308-
} else {
309-
this.close();
310-
}
311-
} else {
312-
if (target instanceof Element && !isWithinMenuElement(target)) {
313-
this.close();
314-
}
315-
}
316-
});
317-
}
318-
319272
/** Subscribe to the menu stack close events and close this menu when requested. */
320273
private _setMenuStackListener() {
321274
this._menuStack.closed.pipe(takeUntil(this._destroyed)).subscribe((item: MenuStackItem) => {
@@ -326,6 +279,23 @@ export class CdkContextMenuTrigger implements OnDestroy {
326279
});
327280
}
328281

282+
/**
283+
* Subscribe to the overlays outside pointer events stream and handle closing out the stack if a
284+
* click occurs outside the menus.
285+
*/
286+
private _subscribeToOutsideClicks() {
287+
if (this._overlayRef) {
288+
this._overlayRef
289+
.outsidePointerEvents()
290+
.pipe(takeUntil(this._stopOutsideClicksListener))
291+
.subscribe(event => {
292+
if (!isClickInsideMenuOverlay(event.target as Element)) {
293+
this._menuStack.closeAll();
294+
}
295+
});
296+
}
297+
}
298+
329299
ngOnDestroy() {
330300
this._destroyOverlay();
331301
this._resetPanelMenuStack();

src/cdk-experimental/menu/menu-bar.spec.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -829,10 +829,8 @@ describe('MenuBar', () => {
829829
openMenu();
830830
expect(popoutMenus.length).toBe(1);
831831

832-
dispatchMouseEvent(
833-
fixture.debugElement.query(By.css('#container')).nativeElement,
834-
'mousedown'
835-
);
832+
833+
fixture.debugElement.query(By.css('#container')).nativeElement.click();
836834
detectChanges();
837835

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

862-
it('should not close open menus when clicking on a menu bar', () => {
863-
openMenu();
864-
expect(popoutMenus.length).toBe(1);
865-
866-
fixture.debugElement.query(By.directive(CdkMenuBar)).nativeElement.click();
867-
detectChanges();
868-
869-
expect(popoutMenus.length).toBe(1);
870-
});
871-
872860
it('should not close when clicking on a CdkMenuItemCheckbox element', () => {
873861
openMenu();
874862
expect(popoutMenus.length).toBe(1);
@@ -894,7 +882,7 @@ describe('MenuBar', () => {
894882
it('should close the open menu when clicking on an inline menu item', () => {
895883
openMenu();
896884

897-
dispatchMouseEvent(nativeInlineMenuItem, 'mousedown');
885+
nativeInlineMenuItem.click();
898886
detectChanges();
899887

900888
expect(popoutMenus.length).toBe(0);

src/cdk-experimental/menu/menu-bar.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,6 @@ import {CdkMenuItem} from './menu-item';
2828
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
2929
import {getItemPointerEntries} from './item-pointer-entries';
3030

31-
/**
32-
* Whether the element is a menu bar or a popup menu.
33-
* @param target the element to check.
34-
* @return true if the given element is part of the menu module.
35-
*/
36-
function isMenuElement(target: Element) {
37-
return (
38-
target.classList.contains('cdk-menu-bar') ||
39-
(target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline'))
40-
);
41-
}
42-
4331
/**
4432
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
4533
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
@@ -259,27 +247,6 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
259247
return this.orientation === 'horizontal';
260248
}
261249

262-
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
263-
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
264-
// can move this back into `host`.
265-
// tslint:disable:no-host-decorator-in-concrete
266-
@HostListener('document:mousedown', ['$event'])
267-
/** Close any open submenu if there was a click event which occurred outside the menu stack. */
268-
_closeOnBackgroundClick(event: MouseEvent) {
269-
if (this._hasOpenSubmenu()) {
270-
// get target from composed path to account for shadow dom
271-
let target = event.composedPath ? event.composedPath()[0] : event.target;
272-
while (target instanceof Element) {
273-
if (isMenuElement(target)) {
274-
return;
275-
}
276-
target = target.parentElement;
277-
}
278-
279-
this._menuStack.closeAll();
280-
}
281-
}
282-
283250
/**
284251
* Subscribe to the menu trigger's open events in order to track the trigger which opened the menu
285252
* and stop tracking it when the menu is closed.

src/cdk-experimental/menu/menu-item-radio.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,14 @@ export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy
4141
private readonly _selectionDispatcher: UniqueSelectionDispatcher,
4242
element: ElementRef<HTMLElement>,
4343
ngZone: NgZone,
44-
@Inject(CDK_MENU) parentMenu: Menu,
44+
@Optional() @Inject(CDK_MENU) parentMenu?: Menu,
4545
@Optional() dir?: Directionality,
4646
/** Reference to the CdkMenuItemTrigger directive if one is added to the same element */
4747
// `CdkMenuItemRadio` is commonly used in combination with a `CdkMenuItemTrigger`.
4848
// tslint:disable-next-line: lightweight-tokens
4949
@Self() @Optional() menuTrigger?: CdkMenuItemTrigger
5050
) {
51-
super(element, parentMenu, ngZone, dir, menuTrigger);
51+
super(element, ngZone, parentMenu, dir, menuTrigger);
5252

5353
this._registerDispatcherListener();
5454
}

0 commit comments

Comments
 (0)