-
Notifications
You must be signed in to change notification settings - Fork 6.8k
/
Copy pathmenu-trigger.ts
377 lines (342 loc) · 11.4 KB
/
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
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
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
/**
* @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 {
ChangeDetectorRef,
Directive,
ElementRef,
inject,
NgZone,
OnChanges,
OnDestroy,
Renderer2,
SimpleChanges,
} from '@angular/core';
import {InputModalityDetector} from '../a11y';
import {Directionality} from '../bidi';
import {
ConnectedPosition,
FlexibleConnectedPositionStrategy,
Overlay,
OverlayConfig,
STANDARD_DROPDOWN_ADJACENT_POSITIONS,
STANDARD_DROPDOWN_BELOW_POSITIONS,
} from '../overlay';
import {
DOWN_ARROW,
ENTER,
hasModifierKey,
LEFT_ARROW,
RIGHT_ARROW,
SPACE,
UP_ARROW,
} from '../keycodes';
import {_getEventTarget} from '../platform';
import {takeUntil} from 'rxjs/operators';
import {CDK_MENU, Menu} from './menu-interface';
import {PARENT_OR_NEW_MENU_STACK_PROVIDER} from './menu-stack';
import {MENU_AIM} from './menu-aim';
import {CdkMenuTriggerBase, MENU_TRIGGER, MenuTracker} from './menu-trigger-base';
import {eventDispatchesNativeClick} from './event-detection';
/**
* A directive that turns its host element into a trigger for a popup menu.
* It can be combined with cdkMenuItem to create sub-menus. If the element is in a top level
* MenuBar it will open the menu on click, or if a sibling is already opened it will open on hover.
* If it is inside of a Menu it will open the attached Submenu on hover regardless of its sibling
* state.
*/
@Directive({
selector: '[cdkMenuTriggerFor]',
exportAs: 'cdkMenuTriggerFor',
host: {
'class': 'cdk-menu-trigger',
'[attr.aria-haspopup]': 'menuTemplateRef ? "menu" : null',
'[attr.aria-expanded]': 'menuTemplateRef == null ? null : isOpen()',
'(focusin)': '_setHasFocus(true)',
'(focusout)': '_setHasFocus(false)',
'(keydown)': '_toggleOnKeydown($event)',
'(click)': '_handleClick()',
},
inputs: [
{name: 'menuTemplateRef', alias: 'cdkMenuTriggerFor'},
{name: 'menuPosition', alias: 'cdkMenuPosition'},
{name: 'menuData', alias: 'cdkMenuTriggerData'},
],
outputs: ['opened: cdkMenuOpened', 'closed: cdkMenuClosed'],
providers: [
{provide: MENU_TRIGGER, useExisting: CdkMenuTrigger},
PARENT_OR_NEW_MENU_STACK_PROVIDER,
],
})
export class CdkMenuTrigger extends CdkMenuTriggerBase implements OnChanges, OnDestroy {
private readonly _elementRef: ElementRef<HTMLElement> = inject(ElementRef);
private readonly _overlay = inject(Overlay);
private readonly _ngZone = inject(NgZone);
private readonly _changeDetectorRef = inject(ChangeDetectorRef);
private readonly _inputModalityDetector = inject(InputModalityDetector);
private readonly _directionality = inject(Directionality, {optional: true});
private readonly _renderer = inject(Renderer2);
private _cleanupMouseenter: () => void;
/** The app's menu tracking registry */
private readonly _menuTracker = inject(MenuTracker);
/** The parent menu this trigger belongs to. */
private readonly _parentMenu = inject(CDK_MENU, {optional: true});
/** The menu aim service used by this menu. */
private readonly _menuAim = inject(MENU_AIM, {optional: true});
constructor() {
super();
this._setRole();
this._registerCloseHandler();
this._subscribeToMenuStackClosed();
this._subscribeToMouseEnter();
this._subscribeToMenuStackHasFocus();
this._setType();
}
/** Toggle the attached menu. */
toggle() {
this.isOpen() ? this.close() : this.open();
}
/** Open the attached menu. */
open() {
if (!this._parentMenu) {
this._menuTracker.update(this);
}
if (!this.isOpen() && this.menuTemplateRef != null) {
this.opened.next();
this.overlayRef = this.overlayRef || this._overlay.create(this._getOverlayConfig());
this.overlayRef.attach(this.getMenuContentPortal());
this._changeDetectorRef.markForCheck();
this._subscribeToOutsideClicks();
}
}
/** Close the opened menu. */
close() {
if (this.isOpen()) {
this.closed.next();
this.overlayRef!.detach();
this._changeDetectorRef.markForCheck();
}
this._closeSiblingTriggers();
}
/**
* Get a reference to the rendered Menu if the Menu is open and rendered in the DOM.
*/
getMenu(): Menu | undefined {
return this.childMenu;
}
ngOnChanges(changes: SimpleChanges) {
if (changes['menuPosition'] && this.overlayRef) {
this.overlayRef.updatePositionStrategy(this._getOverlayPositionStrategy());
}
}
override ngOnDestroy(): void {
this._cleanupMouseenter();
super.ngOnDestroy();
}
/**
* Handles keyboard events for the menu item.
* @param event The keyboard event to handle
*/
_toggleOnKeydown(event: KeyboardEvent) {
const isParentVertical = this._parentMenu?.orientation === 'vertical';
switch (event.keyCode) {
case SPACE:
case ENTER:
// Skip events that will trigger clicks so the handler doesn't get triggered twice.
if (!hasModifierKey(event) && !eventDispatchesNativeClick(this._elementRef, event)) {
this.toggle();
this.childMenu?.focusFirstItem('keyboard');
}
break;
case RIGHT_ARROW:
if (!hasModifierKey(event)) {
if (this._parentMenu && isParentVertical && this._directionality?.value !== 'rtl') {
event.preventDefault();
this.open();
this.childMenu?.focusFirstItem('keyboard');
}
}
break;
case LEFT_ARROW:
if (!hasModifierKey(event)) {
if (this._parentMenu && isParentVertical && this._directionality?.value === 'rtl') {
event.preventDefault();
this.open();
this.childMenu?.focusFirstItem('keyboard');
}
}
break;
case DOWN_ARROW:
case UP_ARROW:
if (!hasModifierKey(event)) {
if (!isParentVertical) {
event.preventDefault();
this.open();
event.keyCode === DOWN_ARROW
? this.childMenu?.focusFirstItem('keyboard')
: this.childMenu?.focusLastItem('keyboard');
}
}
break;
}
}
/** Handles clicks on the menu trigger. */
_handleClick() {
this.toggle();
this.childMenu?.focusFirstItem('mouse');
}
/**
* Sets whether the trigger's menu stack has focus.
* @param hasFocus Whether the menu stack has focus.
*/
_setHasFocus(hasFocus: boolean) {
if (!this._parentMenu) {
this.menuStack.setHasFocus(hasFocus);
}
}
/**
* Subscribe to the mouseenter events and close any sibling menu items if this element is moused
* into.
*/
private _subscribeToMouseEnter() {
this._cleanupMouseenter = this._ngZone.runOutsideAngular(() => {
return this._renderer.listen(this._elementRef.nativeElement, 'mouseenter', () => {
if (
// Skip fake `mouseenter` events dispatched by touch devices.
this._inputModalityDetector.mostRecentModality !== 'touch' &&
!this.menuStack.isEmpty() &&
!this.isOpen()
) {
// Closes any sibling menu items and opens the menu associated with this trigger.
const toggleMenus = () =>
this._ngZone.run(() => {
this._closeSiblingTriggers();
this.open();
});
if (this._menuAim) {
this._menuAim.toggle(toggleMenus);
} else {
toggleMenus();
}
}
});
});
}
/** Close out any sibling menu trigger menus. */
private _closeSiblingTriggers() {
if (this._parentMenu) {
// If nothing was removed from the stack and the last element is not the parent item
// that means that the parent menu is a menu bar since we don't put the menu bar on the
// stack
const isParentMenuBar =
!this.menuStack.closeSubMenuOf(this._parentMenu) &&
this.menuStack.peek() !== this._parentMenu;
if (isParentMenuBar) {
this.menuStack.closeAll();
}
} else {
this.menuStack.closeAll();
}
}
/** Get the configuration object used to create the overlay. */
private _getOverlayConfig() {
return new OverlayConfig({
positionStrategy: this._getOverlayPositionStrategy(),
scrollStrategy: this.menuScrollStrategy(),
direction: this._directionality || undefined,
});
}
/** Build the position strategy for the overlay which specifies where to place the menu. */
private _getOverlayPositionStrategy(): FlexibleConnectedPositionStrategy {
return this._overlay
.position()
.flexibleConnectedTo(this._elementRef)
.withLockedPosition()
.withFlexibleDimensions(false)
.withPositions(this._getOverlayPositions());
}
/** Get the preferred positions for the opened menu relative to the menu item. */
private _getOverlayPositions(): ConnectedPosition[] {
return (
this.menuPosition ??
(!this._parentMenu || this._parentMenu.orientation === 'horizontal'
? STANDARD_DROPDOWN_BELOW_POSITIONS
: STANDARD_DROPDOWN_ADJACENT_POSITIONS)
);
}
/**
* Subscribe to the MenuStack close events if this is a standalone trigger and close out the menu
* this triggers when requested.
*/
private _registerCloseHandler() {
if (!this._parentMenu) {
this.menuStack.closed.pipe(takeUntil(this.destroyed)).subscribe(({item}) => {
if (item === this.childMenu) {
this.close();
}
});
}
}
/**
* 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 => {
const target = _getEventTarget(event) as Element;
const element = this._elementRef.nativeElement;
if (target !== element && !element.contains(target)) {
if (!this.isElementInsideMenuStack(target)) {
this.menuStack.closeAll();
} else {
this._closeSiblingTriggers();
}
}
});
}
}
/** Subscribe to the MenuStack hasFocus events. */
private _subscribeToMenuStackHasFocus() {
if (!this._parentMenu) {
this.menuStack.hasFocus.pipe(takeUntil(this.destroyed)).subscribe(hasFocus => {
if (!hasFocus) {
this.menuStack.closeAll();
}
});
}
}
/** Subscribe to the MenuStack closed events. */
private _subscribeToMenuStackClosed() {
if (!this._parentMenu) {
this.menuStack.closed.subscribe(({focusParentTrigger}) => {
if (focusParentTrigger && !this.menuStack.length()) {
this._elementRef.nativeElement.focus();
}
});
}
}
/** Sets the role attribute for this trigger if needed. */
private _setRole() {
// If this trigger is part of another menu, the cdkMenuItem directive will handle setting the
// role, otherwise this is a standalone trigger, and we should ensure it has role="button".
if (!this._parentMenu) {
this._elementRef.nativeElement.setAttribute('role', 'button');
}
}
/** Sets thte `type` attribute of the trigger. */
private _setType() {
const element = this._elementRef.nativeElement;
if (element.nodeName === 'BUTTON' && !element.getAttribute('type')) {
// Prevents form submissions.
element.setAttribute('type', 'button');
}
}
}