Skip to content

Commit ef73bff

Browse files
committed
feat(cdk-experimental/menu): add ability to open menus from a standalone trigger
Adds the ability to open a menu from a menu trigger placed outside of a menu or menubar.
1 parent 99ad347 commit ef73bff

10 files changed

+422
-124
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, Inject, InjectionToken, OnDestroy} from '@angular/core';
10+
import {DOCUMENT} from '@angular/common';
11+
import {fromEvent, Subject} from 'rxjs';
12+
import {takeUntil} from 'rxjs/operators';
13+
import {MenuStack} from './menu-stack';
14+
15+
/**
16+
* A function which decides whether the background click event should cause the menu stack to close
17+
* out. It is provided alongside the MenuStack to the BackgroundClickService and is called on each
18+
* background click event.
19+
*/
20+
export type CloseDecider = (target: Element | null) => boolean;
21+
22+
/** Injection token for CloseDecider function. */
23+
export const CLOSE_DECIDER = new InjectionToken<CloseDecider>('cdk-menu-close-decider');
24+
25+
/**
26+
* BackgroundClickService listens for background click events when started and performs menu
27+
* closing actions. When the service detects a background click, it calls upon the CloseDecider
28+
* function in order to determine if the current MenuStack should be closed out.
29+
*
30+
* Note that it is acceptable to start the listener from nested triggers as new listeners will not
31+
* be added for the same MenuStack. If the service is started with a new MenuStack, the previous
32+
* set of menus is closed out resulting in only having a single set of open menus at any given time.
33+
*/
34+
@Injectable({providedIn: 'root'})
35+
export class BackgroundClickService implements OnDestroy {
36+
/** Reference to the document. */
37+
private readonly _document: Document;
38+
39+
/** A callback function which determines if the menu stack should be closed out. */
40+
private _shouldCloseMenu?: CloseDecider;
41+
42+
/** The menu stack for the current set of open menus (if any menus are open). */
43+
private _menuStack?: MenuStack;
44+
45+
/** Emits when the background listener should stop listening. */
46+
private readonly _stopListener: Subject<void> = new Subject();
47+
48+
constructor(@Inject(DOCUMENT) document: any) {
49+
this._document = document;
50+
}
51+
52+
/**
53+
* Start listening to background click events. If a background click occurred, as decided by the
54+
* `shouldCloseMenu` function, the service closes out the entire MenuStack.
55+
* @param shouldCloseMenu a function which decides if the background click event should close out
56+
* the menu stack.
57+
* @param menuStack the menu stack for the current open menus.
58+
*/
59+
startListener(shouldCloseMenu: CloseDecider, menuStack: MenuStack) {
60+
// If the current menu stack and the new menu stack are the same we don't want to register
61+
// another listener or close out the current stack. This may occur if submenu triggers open up
62+
// their menus and register with the service.
63+
if (this._menuStack !== menuStack) {
64+
// By default we want to only have a single stack of menus open at any given time regardless
65+
// of the trigger.
66+
this._closePreviousMenuStack();
67+
68+
this._setCloseDecider(shouldCloseMenu);
69+
this._setMenuStack(menuStack);
70+
71+
this._subscribeToStackEmptied();
72+
this._startBackgroundListener();
73+
}
74+
}
75+
76+
/** Close out the previous stack and stop the previous click listener. */
77+
private _closePreviousMenuStack() {
78+
this._menuStack?.closeAll();
79+
}
80+
81+
/** Set the CloseDecider callback function. */
82+
private _setCloseDecider(shouldCloseMenu: CloseDecider) {
83+
this._shouldCloseMenu = shouldCloseMenu;
84+
}
85+
86+
/** Set the menu stack. */
87+
private _setMenuStack(menuStack: MenuStack) {
88+
this._menuStack = menuStack;
89+
}
90+
91+
/** When the menu stack is empty reset the BackgroundClickService to its default state. */
92+
private _subscribeToStackEmptied() {
93+
this._menuStack?.emptied.pipe(takeUntil(this._stopListener)).subscribe(() => {
94+
this._resetState();
95+
this._stopBackgroundListener();
96+
});
97+
}
98+
99+
/** Stops the background click listener and resets the menu stack and callback. */
100+
private _stopBackgroundListener() {
101+
this._stopListener.next();
102+
}
103+
104+
/** Unset the menu stack and close handler callback. */
105+
private _resetState() {
106+
this._shouldCloseMenu = undefined;
107+
this._menuStack = undefined;
108+
}
109+
110+
/**
111+
* Start listening the background click events and close out the menu stack if a click occurs on
112+
* a background element as determined by the provided `CloseDecider` callback.
113+
*/
114+
private _startBackgroundListener() {
115+
fromEvent<MouseEvent>(this._document, 'mousedown')
116+
.pipe(takeUntil(this._stopListener))
117+
.subscribe(event => {
118+
const target = event.composedPath ? event.composedPath()[0] : event.target;
119+
if (target instanceof HTMLElement && this._shouldCloseMenu!(target)) {
120+
this._menuStack!.closeAll();
121+
}
122+
});
123+
}
124+
125+
ngOnDestroy() {
126+
this._stopListener.next();
127+
this._stopListener.complete();
128+
}
129+
}

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ describe('CdkContextMenuTrigger', () => {
5656
it('should close out the context menu when clicking in the context', () => {
5757
openContextMenu();
5858

59-
getMenuContext().click();
59+
dispatchMouseEvent(getMenuContext(), 'mousedown');
6060
fixture.detectChanges();
6161

6262
expect(getContextMenu()).not.toBeDefined();
@@ -65,7 +65,7 @@ describe('CdkContextMenuTrigger', () => {
6565
it('should close out the context menu when clicking on element outside of the context', () => {
6666
openContextMenu();
6767

68-
fixture.nativeElement.querySelector('#other').click();
68+
dispatchMouseEvent(fixture.nativeElement.querySelector('#other'), 'mousedown');
6969
fixture.detectChanges();
7070

7171
expect(getContextMenu()).not.toBeDefined();
@@ -202,7 +202,7 @@ describe('CdkContextMenuTrigger', () => {
202202
it('should close nested context menu when clicking in parent', () => {
203203
openCopyContextMenu();
204204

205-
getCutMenuContext().click();
205+
dispatchMouseEvent(getCutMenuContext(), 'mousedown');
206206
fixture.detectChanges();
207207

208208
expect(getCopyMenu()).not.toBeDefined();
@@ -211,7 +211,7 @@ describe('CdkContextMenuTrigger', () => {
211211
it('should close parent context menu when clicking in nested menu', () => {
212212
openCutContextMenu();
213213

214-
getCopyMenuContext().click();
214+
dispatchMouseEvent(getCopyMenuContext(), 'mousedown');
215215
fixture.detectChanges();
216216

217217
expect(getCutMenu()).not.toBeDefined();

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

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
InjectionToken,
2020
isDevMode,
2121
} from '@angular/core';
22-
import {DOCUMENT} from '@angular/common';
2322
import {Directionality} from '@angular/cdk/bidi';
2423
import {
2524
OverlayRef,
@@ -30,25 +29,26 @@ import {
3029
} from '@angular/cdk/overlay';
3130
import {TemplatePortal, Portal} from '@angular/cdk/portal';
3231
import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion';
33-
import {fromEvent, merge, Subject} from 'rxjs';
32+
import {Subject} from 'rxjs';
3433
import {takeUntil} from 'rxjs/operators';
3534
import {CdkMenuPanel} from './menu-panel';
3635
import {MenuStack, MenuStackItem} from './menu-stack';
3736
import {throwExistingMenuStackError} from './menu-errors';
37+
import {BackgroundClickService} from './background-click-service';
3838

3939
/**
40-
* Check if the given element is part of the cdk menu module or nested within a cdk menu element.
40+
* Whether the context menu should close when some element is clicked.
4141
* @param target the element to check.
42-
* @return true if the given element is part of the menu module or nested within a cdk menu element.
42+
* @return true if the context menu should close.
4343
*/
44-
function isWithinMenuElement(target: Element | null) {
45-
while (target instanceof Element) {
44+
function shouldCloseMenu(target: Element | null) {
45+
while (target) {
4646
if (target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline')) {
47-
return true;
47+
return false;
4848
}
4949
target = target.parentElement;
5050
}
51-
return false;
51+
return true;
5252
}
5353

5454
/** Tracks the last open context menu trigger across the entire application. */
@@ -149,12 +149,6 @@ export class CdkContextMenuTrigger implements OnDestroy {
149149
/** Emits when the element is destroyed. */
150150
private readonly _destroyed: Subject<void> = new Subject();
151151

152-
/** Reference to the document. */
153-
private readonly _document: Document;
154-
155-
/** Emits when the document listener should stop. */
156-
private readonly _stopDocumentListener = merge(this.closed, this._destroyed);
157-
158152
/** The menu stack for this trigger and its associated menus. */
159153
private readonly _menuStack = new MenuStack();
160154

@@ -163,11 +157,9 @@ export class CdkContextMenuTrigger implements OnDestroy {
163157
private readonly _overlay: Overlay,
164158
private readonly _contextMenuTracker: ContextMenuTracker,
165159
@Inject(CDK_CONTEXT_MENU_DEFAULT_OPTIONS) private readonly _options: ContextMenuOptions,
166-
@Inject(DOCUMENT) document: any,
160+
private readonly _closeService: BackgroundClickService,
167161
@Optional() private readonly _directionality?: Directionality
168162
) {
169-
this._document = document;
170-
171163
this._setMenuStackListener();
172164
}
173165

@@ -198,7 +190,8 @@ export class CdkContextMenuTrigger implements OnDestroy {
198190
}
199191

200192
this._overlayRef.attach(this._getMenuContent());
201-
this._setCloseListener();
193+
194+
this._closeService.startListener(shouldCloseMenu, this._menuStack);
202195
}
203196
}
204197

@@ -293,32 +286,6 @@ export class CdkContextMenuTrigger implements OnDestroy {
293286
return this._panelContent;
294287
}
295288

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

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

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
Optional,
1717
NgZone,
1818
HostListener,
19+
InjectionToken,
1920
} from '@angular/core';
2021
import {Directionality} from '@angular/cdk/bidi';
2122
import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y';
@@ -27,19 +28,28 @@ import {CDK_MENU, Menu} from './menu-interface';
2728
import {CdkMenuItem} from './menu-item';
2829
import {MenuStack, MenuStackItem, FocusNext} from './menu-stack';
2930
import {getItemPointerEntries} from './item-pointer-entries';
30-
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-
);
31+
import {CLOSE_DECIDER, CloseDecider} from './background-click-service';
32+
33+
/** Whether the menu stack should be closed when target is clicked. */
34+
export function shouldCloseMenu(target: Element | null) {
35+
while (target) {
36+
if (
37+
target.classList.contains('cdk-menu-bar') ||
38+
(target.classList.contains('cdk-menu') && !target.classList.contains('cdk-menu-inline'))
39+
) {
40+
return false;
41+
}
42+
target = target.parentElement;
43+
}
44+
return true;
4145
}
4246

47+
/** Provider for the background click decider function for the MenuBar. */
48+
export const MENUBAR_CLOSE_PROVIDER: {
49+
provide: InjectionToken<CloseDecider>;
50+
useValue: (target: Element | null) => boolean;
51+
} = {provide: CLOSE_DECIDER, useValue: shouldCloseMenu};
52+
4353
/**
4454
* Directive applied to an element which configures it as a MenuBar by setting the appropriate
4555
* role, aria attributes, and accessible keyboard and mouse handling logic. The component that
@@ -59,6 +69,7 @@ function isMenuElement(target: Element) {
5969
{provide: CdkMenuGroup, useExisting: CdkMenuBar},
6070
{provide: CDK_MENU, useExisting: CdkMenuBar},
6171
{provide: MenuStack, useClass: MenuStack},
72+
MENUBAR_CLOSE_PROVIDER,
6273
],
6374
})
6475
export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy {
@@ -259,27 +270,6 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,
259270
return this.orientation === 'horizontal';
260271
}
261272

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-
283273
/**
284274
* Subscribe to the menu trigger's open events in order to track the trigger which opened the menu
285275
* 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)