-
Notifications
You must be signed in to change notification settings - Fork 6.7k
/
context-menu.ts
359 lines (316 loc) · 12.4 KB
/
context-menu.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
/**
* @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';
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;
}
/** 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<ContextMenuOptions>(
'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) {
if ((typeof ngDevMode === 'undefined' || ngDevMode) && panel._menuStack) {
throwExistingMenuStackError();
}
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<void> = new EventEmitter();
/** Emits when the attached menu is requested to close. */
@Output('cdkContextMenuClosed') readonly closed: EventEmitter<void> = 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<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();
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});
// A context menu can be triggered via a mouse right click or a keyboard shortcut.
if (event.button === 2) {
this._menuPanel._menu?.focusFirstItem('mouse');
} else if (event.button === 0) {
this._menuPanel._menu?.focusFirstItem('keyboard');
} else {
this._menuPanel._menu?.focusFirstItem('program');
}
}
}
/** 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<unknown> {
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._resetPanelMenuStack();
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;
}
}
/** Set the menu panels menu stack back to null. */
private _resetPanelMenuStack() {
// If a ContextMenuTrigger is placed in a conditionally rendered view, each time the trigger is
// rendered the panel setter for ContextMenuTrigger is called. From the first render onward,
// the attached CdkMenuPanel has the MenuStack set. Since we throw an error if a panel already
// has a stack set, we want to reset the attached stack here to prevent the error from being
// thrown if the trigger re-configures its attached panel (in the case where there is a 1:1
// relationship between the panel and trigger).
if (this._menuPanel) {
this._menuPanel._menuStack = null;
}
}
static ngAcceptInputType_disabled: BooleanInput;
}