Skip to content

Commit 564e7ad

Browse files
committed
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 633207d commit 564e7ad

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+
}

0 commit comments

Comments
 (0)