Skip to content

Commit 9e942b4

Browse files
committedJun 24, 2025
feat(material/menu): add support for context menu
Adds the new `MatContextMenuTrigger` directive that allows users to mark an area as a trigger for a menu. When the user right-clicks inside of the area, the menu will be opened next to their pointer. Fixes #5007.
1 parent 867cee7 commit 9e942b4

File tree

8 files changed

+480
-1
lines changed

8 files changed

+480
-1
lines changed
 

‎goldens/material/menu/index.api.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,40 @@ export const MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER: {
4949
useFactory: typeof MAT_MENU_SCROLL_STRATEGY_FACTORY;
5050
};
5151

52+
// @public
53+
export class MatContextMenuTrigger extends MatMenuTriggerBase implements OnDestroy {
54+
constructor();
55+
// (undocumented)
56+
protected _destroyMenu(reason: MenuCloseReason): void;
57+
disabled: boolean;
58+
// (undocumented)
59+
protected _getOutsideClickStream(overlayRef: OverlayRef): rxjs.Observable<MouseEvent>;
60+
// (undocumented)
61+
protected _getOverlayOrigin(): {
62+
x: number;
63+
y: number;
64+
initialX: number;
65+
initialY: number;
66+
initialScrollX: number;
67+
initialScrollY: number;
68+
};
69+
protected _handleContextMenuEvent(event: MouseEvent): void;
70+
get menu(): MatMenuPanel | null;
71+
set menu(menu: MatMenuPanel | null);
72+
readonly menuClosed: EventEmitter<void>;
73+
menuData: any;
74+
readonly menuOpened: EventEmitter<void>;
75+
// (undocumented)
76+
static ngAcceptInputType_disabled: unknown;
77+
// (undocumented)
78+
ngOnDestroy(): void;
79+
restoreFocus: boolean;
80+
// (undocumented)
81+
static ɵdir: i0.ɵɵDirectiveDeclaration<MatContextMenuTrigger, "[matContextMenuTriggerFor]", ["matContextMenuTrigger"], { "menu": { "alias": "matContextMenuTriggerFor"; "required": true; }; "menuData": { "alias": "matContextMenuTriggerData"; "required": false; }; "restoreFocus": { "alias": "matContextMenuTriggerRestoreFocus"; "required": false; }; "disabled": { "alias": "matContextMenuTriggerDisabled"; "required": false; }; }, { "menuOpened": "menuOpened"; "menuClosed": "menuClosed"; }, never, never, true, never>;
82+
// (undocumented)
83+
static ɵfac: i0.ɵɵFactoryDeclaration<MatContextMenuTrigger, never>;
84+
}
85+
5286
// @public (undocumented)
5387
export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnInit, OnDestroy {
5488
constructor(...args: unknown[]);

‎src/dev-app/menu/menu-demo.html

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,29 @@
194194
</div>
195195
</div>
196196

197-
<div style="height: 500px">This div is for testing scrolled menus.</div>
197+
<div class="demo-context-menu-area" [matContextMenuTriggerFor]="contextMenu">
198+
Right click here to trigger a context menu
199+
</div>
200+
201+
<mat-menu #contextMenu>
202+
<button mat-menu-item>
203+
<mat-icon>content_cut</mat-icon>
204+
Cut
205+
</button>
206+
<button mat-menu-item>
207+
<mat-icon>content_copy</mat-icon>
208+
Copy
209+
</button>
210+
<button mat-menu-item>
211+
<mat-icon>content_paste</mat-icon>
212+
Paste
213+
</button>
214+
<button mat-menu-item>
215+
<mat-icon>print</mat-icon>
216+
Print
217+
</button>
218+
</mat-menu>
219+
220+
<div style="height: 500px">
221+
<!-- Makes the page scrollable for easier testing. -->
222+
</div>

‎src/dev-app/menu/menu-demo.scss

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,13 @@
1111
justify-content: flex-end;
1212
}
1313
}
14+
15+
.demo-context-menu-area {
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
width: 100%;
20+
height: 500px;
21+
max-width: 500px;
22+
outline: dashed 1px;
23+
}

‎src/material/menu/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ sass_binary(
6767
ng_project(
6868
name = "menu",
6969
srcs = [
70+
"context-menu-trigger.ts",
7071
"index.ts",
7172
"menu.ts",
7273
"menu-animations.ts",
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import {Component, signal} from '@angular/core';
2+
import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing';
3+
import {MatContextMenuTrigger} from './context-menu-trigger';
4+
import {MatMenu} from './menu';
5+
import {MatMenuItem} from './menu-item';
6+
import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing/private';
7+
8+
describe('context menu trigger', () => {
9+
let fixture: ComponentFixture<ContextMenuTest>;
10+
11+
function getTrigger(): HTMLElement {
12+
return fixture.nativeElement.querySelector('.area');
13+
}
14+
15+
function getMenu(): HTMLElement | null {
16+
return document.querySelector('.mat-mdc-menu-panel');
17+
}
18+
19+
beforeEach(() => {
20+
fixture = TestBed.createComponent(ContextMenuTest);
21+
fixture.detectChanges();
22+
});
23+
24+
it('should open the menu on the `contextmenu` event', () => {
25+
expect(getMenu()).toBe(null);
26+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
27+
fixture.detectChanges();
28+
expect(getMenu()).toBeTruthy();
29+
});
30+
31+
it('should close the menu when clicking outside the trigger', fakeAsync(() => {
32+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
33+
fixture.detectChanges();
34+
expect(getMenu()).toBeTruthy();
35+
36+
document.body.click();
37+
fixture.detectChanges();
38+
flush();
39+
expect(getMenu()).toBe(null);
40+
}));
41+
42+
it('should reposition the menu when right-clicking within the area', () => {
43+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
44+
fixture.detectChanges();
45+
let menuRect = getMenu()!.getBoundingClientRect();
46+
expect(menuRect.top).toBe(10);
47+
expect(menuRect.left).toBe(10);
48+
49+
dispatchMouseEvent(getTrigger(), 'contextmenu', 50, 75);
50+
fixture.detectChanges();
51+
menuRect = getMenu()!.getBoundingClientRect();
52+
expect(menuRect.top).toBe(75);
53+
expect(menuRect.left).toBe(50);
54+
});
55+
56+
it('should ignore the first auxclick after opening', fakeAsync(() => {
57+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
58+
fixture.detectChanges();
59+
expect(getMenu()).toBeTruthy();
60+
61+
dispatchMouseEvent(document.body, 'auxclick');
62+
fixture.detectChanges();
63+
flush();
64+
expect(getMenu()).toBeTruthy();
65+
66+
dispatchMouseEvent(document.body, 'auxclick');
67+
fixture.detectChanges();
68+
flush();
69+
expect(getMenu()).toBe(null);
70+
}));
71+
72+
it('should close on `contextmenu` events outside the trigger', fakeAsync(() => {
73+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
74+
fixture.detectChanges();
75+
expect(getMenu()).toBeTruthy();
76+
77+
dispatchMouseEvent(document.body, 'contextmenu');
78+
fixture.detectChanges();
79+
flush();
80+
expect(getMenu()).toBe(null);
81+
}));
82+
83+
it('should not close on `contextmenu` events from inside the menu', fakeAsync(() => {
84+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
85+
fixture.detectChanges();
86+
expect(getMenu()).toBeTruthy();
87+
88+
dispatchMouseEvent(getMenu()!, 'contextmenu');
89+
fixture.detectChanges();
90+
flush();
91+
expect(getMenu()).toBeTruthy();
92+
}));
93+
94+
it('should set aria-controls on the trigger while the menu is open', () => {
95+
expect(getTrigger().getAttribute('aria-controls')).toBe(null);
96+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
97+
fixture.detectChanges();
98+
expect(getTrigger().getAttribute('aria-controls')).toBeTruthy();
99+
});
100+
101+
it('should reposition the menu as the user is scrolling', () => {
102+
const scroller = document.createElement('div');
103+
scroller.style.height = '1000px';
104+
fixture.nativeElement.appendChild(scroller);
105+
106+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
107+
fixture.detectChanges();
108+
let menuRect = getMenu()!.getBoundingClientRect();
109+
expect(menuRect.top).toBe(10);
110+
expect(menuRect.left).toBe(10);
111+
112+
scrollTo(0, 100);
113+
dispatchFakeEvent(document, 'scroll');
114+
fixture.detectChanges();
115+
menuRect = getMenu()!.getBoundingClientRect();
116+
expect(menuRect.top).toBe(-90);
117+
expect(menuRect.left).toBe(10);
118+
119+
window.scroll(0, 0);
120+
scroller.remove();
121+
});
122+
123+
it('should emit events when the menu is opened and closed', fakeAsync(() => {
124+
const {opened, closed} = fixture.componentInstance;
125+
expect(opened).toHaveBeenCalledTimes(0);
126+
expect(closed).toHaveBeenCalledTimes(0);
127+
128+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
129+
fixture.detectChanges();
130+
expect(opened).toHaveBeenCalledTimes(1);
131+
expect(closed).toHaveBeenCalledTimes(0);
132+
133+
document.body.click();
134+
fixture.detectChanges();
135+
flush();
136+
expect(opened).toHaveBeenCalledTimes(1);
137+
expect(closed).toHaveBeenCalledTimes(1);
138+
}));
139+
140+
it('should close the menu if the trigger is destroyed', fakeAsync(() => {
141+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
142+
fixture.detectChanges();
143+
expect(getMenu()).toBeTruthy();
144+
145+
fixture.componentInstance.showTrigger.set(false);
146+
fixture.detectChanges();
147+
flush();
148+
expect(getMenu()).toBe(null);
149+
}));
150+
151+
it('should not open when clicking on a disabled context menu trigger', () => {
152+
fixture.componentInstance.disabled.set(true);
153+
fixture.detectChanges();
154+
expect(getTrigger().classList).toContain('mat-context-menu-trigger-disabled');
155+
expect(getMenu()).toBe(null);
156+
dispatchMouseEvent(getTrigger(), 'contextmenu', 10, 10);
157+
fixture.detectChanges();
158+
expect(getMenu()).toBe(null);
159+
});
160+
});
161+
162+
@Component({
163+
template: `
164+
@if (showTrigger()) {
165+
<div
166+
class="area"
167+
[matContextMenuTriggerFor]="menu"
168+
[matContextMenuTriggerDisabled]="disabled()"
169+
(menuOpened)="opened()"
170+
(menuClosed)="closed()"></div>
171+
}
172+
<mat-menu #menu>
173+
<button mat-menu-item>One</button>
174+
<button mat-menu-item>Two</button>
175+
<button mat-menu-item>Three</button>
176+
</mat-menu>
177+
`,
178+
imports: [MatContextMenuTrigger, MatMenu, MatMenuItem],
179+
styles: `
180+
.area {
181+
width: 200px;
182+
height: 200px;
183+
outline: solid 1px;
184+
}
185+
`,
186+
})
187+
class ContextMenuTest {
188+
showTrigger = signal(true);
189+
disabled = signal(false);
190+
opened = jasmine.createSpy('opened');
191+
closed = jasmine.createSpy('closed');
192+
}
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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.dev/license
7+
*/
8+
9+
import {
10+
booleanAttribute,
11+
Directive,
12+
DOCUMENT,
13+
EventEmitter,
14+
inject,
15+
Input,
16+
OnDestroy,
17+
Output,
18+
} from '@angular/core';
19+
import {MatMenuTriggerBase} from './menu-trigger-base';
20+
import {
21+
FlexibleConnectedPositionStrategy,
22+
OverlayRef,
23+
ScrollDispatcher,
24+
ViewportRuler,
25+
} from '@angular/cdk/overlay';
26+
import {_getEventTarget, _getShadowRoot} from '@angular/cdk/platform';
27+
import {Subscription} from 'rxjs';
28+
import {skipWhile} from 'rxjs/operators';
29+
import {MatMenuPanel} from './menu-panel';
30+
import {_animationsDisabled} from '../core';
31+
import {MenuCloseReason} from './menu';
32+
33+
/**
34+
* Trigger that opens a menu whenever the user right-clicks within its host element.
35+
*/
36+
@Directive({
37+
selector: '[matContextMenuTriggerFor]',
38+
host: {
39+
'class': 'mat-context-menu-trigger',
40+
'[class.mat-context-menu-trigger-disabled]': 'disabled',
41+
'[attr.aria-controls]': 'menuOpen ? menu?.panelId : null',
42+
'(contextmenu)': '_handleContextMenuEvent($event)',
43+
},
44+
exportAs: 'matContextMenuTrigger',
45+
})
46+
export class MatContextMenuTrigger extends MatMenuTriggerBase implements OnDestroy {
47+
private _point = {x: 0, y: 0, initialX: 0, initialY: 0, initialScrollX: 0, initialScrollY: 0};
48+
private _triggerPressedControl = false;
49+
private _rootNode: DocumentOrShadowRoot | undefined;
50+
private _document = inject(DOCUMENT);
51+
private _viewportRuler = inject(ViewportRuler);
52+
private _scrollDispatcher = inject(ScrollDispatcher);
53+
private _scrollSubscription: Subscription | undefined;
54+
55+
/** References the menu instance that the trigger is associated with. */
56+
@Input({alias: 'matContextMenuTriggerFor', required: true})
57+
get menu(): MatMenuPanel | null {
58+
return this._menu;
59+
}
60+
set menu(menu: MatMenuPanel | null) {
61+
this._menu = menu;
62+
}
63+
64+
/** Data to be passed along to any lazily-rendered content. */
65+
@Input('matContextMenuTriggerData')
66+
override menuData: any;
67+
68+
/**
69+
* Whether focus should be restored when the menu is closed.
70+
* Note that disabling this option can have accessibility implications
71+
* and it's up to you to manage focus, if you decide to turn it off.
72+
*/
73+
@Input('matContextMenuTriggerRestoreFocus')
74+
override restoreFocus: boolean = true;
75+
76+
/** Whether the context menu is disabled. */
77+
@Input({alias: 'matContextMenuTriggerDisabled', transform: booleanAttribute})
78+
disabled: boolean = false;
79+
80+
/** Event emitted when the associated menu is opened. */
81+
@Output()
82+
readonly menuOpened: EventEmitter<void> = new EventEmitter<void>();
83+
84+
/** Event emitted when the associated menu is closed. */
85+
@Output() readonly menuClosed: EventEmitter<void> = new EventEmitter<void>();
86+
87+
constructor() {
88+
super(false);
89+
}
90+
91+
override ngOnDestroy(): void {
92+
super.ngOnDestroy();
93+
this._scrollSubscription?.unsubscribe();
94+
}
95+
96+
/** Handler for `contextmenu` events. */
97+
protected _handleContextMenuEvent(event: MouseEvent) {
98+
if (!this.disabled) {
99+
event.preventDefault();
100+
101+
// If the menu is already open, only update its position.
102+
if (this.menuOpen) {
103+
this._initializePoint(event.clientX, event.clientY);
104+
this._updatePosition();
105+
} else {
106+
this._openContextMenu(event);
107+
}
108+
}
109+
}
110+
111+
protected override _destroyMenu(reason: MenuCloseReason): void {
112+
super._destroyMenu(reason);
113+
this._scrollSubscription?.unsubscribe();
114+
}
115+
116+
protected override _getOverlayOrigin() {
117+
return this._point;
118+
}
119+
120+
protected override _getOutsideClickStream(overlayRef: OverlayRef) {
121+
return overlayRef.outsidePointerEvents().pipe(
122+
skipWhile((event, index) => {
123+
if (event.type === 'contextmenu') {
124+
// Do not close when attempting to open a context menu within the trigger.
125+
return this._isWithinMenuOrTrigger(_getEventTarget(event) as Element);
126+
} else if (event.type === 'auxclick') {
127+
// Skip the first `auxclick` since it happens at
128+
// the same time as the event that opens the menu.
129+
if (index === 0) {
130+
return true;
131+
}
132+
133+
// Do not close on `auxclick` within the menu since we want to reposition the menu
134+
// instead. Note that we have to resolve the clicked element using its position,
135+
// rather than `event.target`, because the `target` is set to the `body`.
136+
this._rootNode ??= _getShadowRoot(this._element.nativeElement) || this._document;
137+
return this._isWithinMenuOrTrigger(
138+
this._rootNode.elementFromPoint(event.clientX, event.clientY),
139+
);
140+
}
141+
142+
// Using a mouse, the `contextmenu` event can fire either when pressing the right button
143+
// or left button + control. Most browsers won't dispatch a `click` event right after
144+
// a `contextmenu` event triggered by left button + control, but Safari will (see #27832).
145+
// This closes the menu immediately. To work around it, we check that both the triggering
146+
// event and the current outside click event both had the control key pressed, and that
147+
// that this is the first outside click event.
148+
return this._triggerPressedControl && index === 0 && event.ctrlKey;
149+
}),
150+
);
151+
}
152+
153+
/** Checks whether an element is within the trigger or the opened overlay. */
154+
private _isWithinMenuOrTrigger(target: Element | null): boolean {
155+
if (!target) {
156+
return false;
157+
}
158+
159+
const element = this._element.nativeElement;
160+
if (target === element || element.contains(target)) {
161+
return true;
162+
}
163+
164+
const overlay = this._overlayRef?.hostElement;
165+
return overlay === target || !!overlay?.contains(target);
166+
}
167+
168+
/** Opens the context menu. */
169+
private _openContextMenu(event: MouseEvent) {
170+
// A context menu can be triggered via a mouse right click or a keyboard shortcut.
171+
if (event.button === 2) {
172+
this._openedBy = 'mouse';
173+
} else {
174+
this._openedBy = event.button === 0 ? 'keyboard' : undefined;
175+
}
176+
177+
this._initializePoint(event.clientX, event.clientY);
178+
this._triggerPressedControl = event.ctrlKey;
179+
super._openMenu(true);
180+
this._scrollSubscription?.unsubscribe();
181+
this._scrollSubscription = this._scrollDispatcher.scrolled(0).subscribe(() => {
182+
// When passing a point to the connected position strategy, the position
183+
// won't update as the user is scrolling so we have to do it manually.
184+
const position = this._viewportRuler.getViewportScrollPosition();
185+
const point = this._point;
186+
point.y = point.initialY + (point.initialScrollY - position.top);
187+
point.x = point.initialX + (point.initialScrollX - position.left);
188+
this._updatePosition();
189+
});
190+
}
191+
192+
/** Initializes the point representing the origin relative to which the menu will be rendered. */
193+
private _initializePoint(x: number, y: number) {
194+
const scrollPosition = this._viewportRuler.getViewportScrollPosition();
195+
const point = this._point;
196+
point.x = point.initialX = x;
197+
point.y = point.initialY = y;
198+
point.initialScrollX = scrollPosition.left;
199+
point.initialScrollY = scrollPosition.top;
200+
}
201+
202+
/** Refreshes the position of the overlay. */
203+
private _updatePosition() {
204+
const overlayRef = this._overlayRef;
205+
206+
if (overlayRef) {
207+
const positionStrategy = overlayRef.getConfig()
208+
.positionStrategy as FlexibleConnectedPositionStrategy;
209+
positionStrategy.setOrigin(this._point);
210+
overlayRef.updatePosition();
211+
}
212+
}
213+
}

‎src/material/menu/module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {MatMenuItem} from './menu-item';
1515
import {MatMenuContent} from './menu-content';
1616
import {MatMenuTrigger} from './menu-trigger';
1717
import {MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER} from './menu-trigger-base';
18+
import {MatContextMenuTrigger} from './context-menu-trigger';
1819

1920
@NgModule({
2021
imports: [
@@ -25,6 +26,7 @@ import {MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER} from './menu-trigger-base';
2526
MatMenuItem,
2627
MatMenuContent,
2728
MatMenuTrigger,
29+
MatContextMenuTrigger,
2830
],
2931
exports: [
3032
CdkScrollableModule,
@@ -33,6 +35,7 @@ import {MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER} from './menu-trigger-base';
3335
MatMenuItem,
3436
MatMenuContent,
3537
MatMenuTrigger,
38+
MatContextMenuTrigger,
3639
],
3740
providers: [MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER],
3841
})

‎src/material/menu/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ export * from './module';
1919
export * from './menu-animations';
2020
export * from './menu-positions';
2121
export * from './menu-panel';
22+
export {MatContextMenuTrigger} from './context-menu-trigger';

0 commit comments

Comments
 (0)
Please sign in to comment.