-
Notifications
You must be signed in to change notification settings - Fork 6.8k
/
Copy pathcontext-menu-trigger.ts
235 lines (209 loc) · 8.31 KB
/
context-menu-trigger.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
/**
* @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.dev/license
*/
import {
booleanAttribute,
ChangeDetectorRef,
Directive,
inject,
Input,
OnDestroy,
} from '@angular/core';
import {Directionality} from '../bidi';
import {
FlexibleConnectedPositionStrategy,
Overlay,
OverlayConfig,
STANDARD_DROPDOWN_BELOW_POSITIONS,
} from '../overlay';
import {_getEventTarget} from '../platform';
import {merge, partition} from 'rxjs';
import {skip, takeUntil, skipWhile} from 'rxjs/operators';
import {MENU_STACK, MenuStack} from './menu-stack';
import {CdkMenuTriggerBase, MENU_TRIGGER, MenuTracker} from './menu-trigger-base';
/** The preferred menu positions for the context menu. */
const CONTEXT_MENU_POSITIONS = STANDARD_DROPDOWN_BELOW_POSITIONS.map(position => {
// In cases where the first menu item in the context menu is a trigger the submenu opens on a
// hover event. We offset the context menu 2px by default to prevent this from occurring.
const offsetX = position.overlayX === 'start' ? 2 : -2;
const offsetY = position.overlayY === 'top' ? 2 : -2;
return {...position, offsetX, offsetY};
});
/** The coordinates where the context menu should open. */
export type ContextMenuCoordinates = {x: number; y: number};
/**
* A directive that opens a menu when a user right-clicks within its host element.
* It is aware of nested context menus and will trigger only the lowest level non-disabled context menu.
*/
@Directive({
selector: '[cdkContextMenuTriggerFor]',
exportAs: 'cdkContextMenuTriggerFor',
host: {
'[attr.data-cdk-menu-stack-id]': 'null',
'(contextmenu)': '_openOnContextMenu($event)',
},
inputs: [
{name: 'menuTemplateRef', alias: 'cdkContextMenuTriggerFor'},
{name: 'menuPosition', alias: 'cdkContextMenuPosition'},
{name: 'menuData', alias: 'cdkContextMenuTriggerData'},
],
outputs: ['opened: cdkContextMenuOpened', 'closed: cdkContextMenuClosed'],
providers: [
{provide: MENU_TRIGGER, useExisting: CdkContextMenuTrigger},
{provide: MENU_STACK, useClass: MenuStack},
],
})
export class CdkContextMenuTrigger extends CdkMenuTriggerBase implements OnDestroy {
/** The CDK overlay service. */
private readonly _overlay = inject(Overlay);
/** The directionality of the page. */
private readonly _directionality = inject(Directionality, {optional: true});
/** The app's menu tracking registry */
private readonly _menuTracker = inject(MenuTracker);
private readonly _changeDetectorRef = inject(ChangeDetectorRef);
/** Whether the context menu is disabled. */
@Input({alias: 'cdkContextMenuDisabled', transform: booleanAttribute}) disabled: boolean = false;
constructor() {
super();
this._setMenuStackCloseListener();
}
/**
* Open the attached menu at the specified location.
* @param coordinates where to open the context menu
*/
open(coordinates: ContextMenuCoordinates) {
this._open(null, coordinates);
this._changeDetectorRef.markForCheck();
}
/** Close the currently opened context menu. */
close() {
this.menuStack.closeAll();
}
/**
* Open the context menu and closes 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._menuTracker.update(this);
this._open(event, {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.childMenu?.focusFirstItem('mouse');
} else if (event.button === 0) {
this.childMenu?.focusFirstItem('keyboard');
} else {
this.childMenu?.focusFirstItem('program');
}
}
}
/**
* 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.menuScrollStrategy(),
direction: this._directionality || undefined,
});
}
/**
* Get 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)
.withLockedPosition()
.withGrowAfterOpen()
.withPositions(this.menuPosition ?? CONTEXT_MENU_POSITIONS);
}
/** Subscribe to the menu stack close events and close this menu when requested. */
private _setMenuStackCloseListener() {
this.menuStack.closed.pipe(takeUntil(this.destroyed)).subscribe(({item}) => {
if (item === this.childMenu && this.isOpen()) {
this.closed.next();
this.overlayRef!.detach();
this.childMenu = undefined;
this._changeDetectorRef.markForCheck();
}
});
}
/**
* Subscribe to the overlays outside pointer events stream and handle closing out the stack if a
* click occurs outside the menus.
* @param userEvent User-generated event that opened the menu.
*/
private _subscribeToOutsideClicks(userEvent: MouseEvent | null) {
if (this.overlayRef) {
let outsideClicks = this.overlayRef.outsidePointerEvents();
if (userEvent) {
const [auxClicks, nonAuxClicks] = partition(outsideClicks, ({type}) => type === 'auxclick');
outsideClicks = merge(
// Using a mouse, the `contextmenu` event can fire either when pressing the right button
// or left button + control. Most browsers won't dispatch a `click` event right after
// a `contextmenu` event triggered by left button + control, but Safari will (see #27832).
// This closes the menu immediately. To work around it, we check that both the triggering
// event and the current outside click event both had the control key pressed, and that
// that this is the first outside click event.
nonAuxClicks.pipe(
skipWhile((event, index) => userEvent.ctrlKey && index === 0 && event.ctrlKey),
),
// If the menu was triggered by the `contextmenu` event, skip the first `auxclick` event
// because it fires when the mouse is released on the same click that opened the menu.
auxClicks.pipe(skip(1)),
);
}
outsideClicks.pipe(takeUntil(this.stopOutsideClicksListener)).subscribe(event => {
if (!this.isElementInsideMenuStack(_getEventTarget(event)!)) {
this.menuStack.closeAll();
}
});
}
}
/**
* Open the attached menu at the specified location.
* @param userEvent User-generated event that opened the menu
* @param coordinates where to open the context menu
*/
private _open(userEvent: MouseEvent | null, coordinates: ContextMenuCoordinates) {
if (this.disabled) {
return;
}
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.childMenu!);
(
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.getMenuContentPortal());
this._subscribeToOutsideClicks(userEvent);
}
}
}