Skip to content

Commit 592f33f

Browse files
trshafermmalerba
authored andcommitted
feat(menu): Added ability to show the menu overlay around the menu trigger (#1771)
1 parent 0420729 commit 592f33f

File tree

7 files changed

+202
-13
lines changed

7 files changed

+202
-13
lines changed

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,54 @@
6262
</md-menu>
6363
</div>
6464
</div>
65+
66+
<div class="demo-menu">
67+
<div class="menu-section">
68+
<p>overlapTrigger: false</p>
69+
70+
<md-toolbar>
71+
<button md-icon-button [md-menu-trigger-for]="menuOverlay">
72+
<md-icon>more_vert</md-icon>
73+
</button>
74+
</md-toolbar>
75+
76+
<md-menu [overlapTrigger]="false" #menuOverlay="mdMenu">
77+
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
78+
{{ item.text }}
79+
</button>
80+
</md-menu>
81+
</div>
82+
<div class="menu-section">
83+
<p>
84+
Position x: before, overlapTrigger: false
85+
</p>
86+
<md-toolbar class="end-icon">
87+
<button md-icon-button [md-menu-trigger-for]="posXMenuOverlay">
88+
<md-icon>more_vert</md-icon>
89+
</button>
90+
</md-toolbar>
91+
92+
<md-menu x-position="before" [overlapTrigger]="false" #posXMenuOverlay="mdMenu" class="before">
93+
<button md-menu-item *ngFor="let item of iconItems" [disabled]="item.disabled">
94+
<md-icon>{{ item.icon }}</md-icon>
95+
{{ item.text }}
96+
</button>
97+
</md-menu>
98+
</div>
99+
<div class="menu-section">
100+
<p>
101+
Position y: above, overlapTrigger: false
102+
</p>
103+
<md-toolbar>
104+
<button md-icon-button [md-menu-trigger-for]="posYMenuOverlay">
105+
<md-icon>more_vert</md-icon>
106+
</button>
107+
</md-toolbar>
108+
109+
<md-menu y-position="above" [overlapTrigger]="false" #posYMenuOverlay="mdMenu">
110+
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
111+
{{ item.text }}
112+
</button>
113+
</md-menu>
114+
</div>
115+
</div>

src/lib/menu/OVERVIEW.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ Menus support displaying `md-icon` elements before the menu item text.
5252

5353
### Customizing menu position
5454

55-
By default, the menu will display after and below its trigger. The position can be changed
55+
By default, the menu will display below (y-axis), after (x-axis), and overlapping its trigger. The position can be changed
5656
using the `x-position` (`before | after`) and `y-position` (`above | below`) attributes.
57+
The menu can be be forced to not overlap the trigger using `[overlapTrigger]="false"` attribute.
5758

5859

5960
### Keyboard interaction

src/lib/menu/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ Output:
115115
### Customizing menu position
116116

117117
By default, the menu will display after and below its trigger. You can change this display position
118-
using the `x-position` (`before | after`) and `y-position` (`above | below`) attributes.
118+
using the `x-position` (`before | after`) and `y-position` (`above | below`) attributes. The menu
119+
can be positioned over the menu button or outside using `overlapTrigger` (`true | false`).
119120

120121
*my-comp.html*
121122
```html
@@ -148,6 +149,7 @@ also adds `aria-hasPopup="true"` to the trigger element.
148149
| --- | --- | --- |
149150
| `x-position` | `before | after` | The horizontal position of the menu in relation to the trigger. Defaults to `after`. |
150151
| `y-position` | `above | below` | The vertical position of the menu in relation to the trigger. Defaults to `below`. |
152+
| `overlapTrigger` | `true | false` | Whether to have the menu show on top of the menu trigger or outside. Defaults to `true`. |
151153

152154
### Trigger Programmatic API
153155

src/lib/menu/menu-directive.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
5252

5353
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
5454
@ContentChildren(MdMenuItem) items: QueryList<MdMenuItem>;
55+
@Input() overlapTrigger = true;
5556

5657
constructor(@Attribute('x-position') posX: MenuPositionX,
5758
@Attribute('y-position') posY: MenuPositionY) {

src/lib/menu/menu-panel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {MenuPositionX, MenuPositionY} from './menu-positions';
44
export interface MdMenuPanel {
55
positionX: MenuPositionX;
66
positionY: MenuPositionY;
7+
overlapTrigger: boolean;
78
templateRef: TemplateRef<any>;
89
close: EventEmitter<void>;
910
focusFirstItem: () => void;

src/lib/menu/menu-trigger.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,12 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
216216
private _subscribeToPositions(position: ConnectedPositionStrategy): void {
217217
this._positionSubscription = position.onPositionChange.subscribe((change) => {
218218
const posX: MenuPositionX = change.connectionPair.originX === 'start' ? 'after' : 'before';
219-
const posY: MenuPositionY = change.connectionPair.originY === 'top' ? 'below' : 'above';
219+
let posY: MenuPositionY = change.connectionPair.originY === 'top' ? 'below' : 'above';
220+
221+
if (!this.menu.overlapTrigger) {
222+
posY = posY === 'below' ? 'above' : 'below';
223+
}
224+
220225
this.menu.setPositionClasses(posX, posY);
221226
});
222227
}
@@ -230,21 +235,29 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
230235
const [posX, fallbackX]: HorizontalConnectionPos[] =
231236
this.menu.positionX === 'before' ? ['end', 'start'] : ['start', 'end'];
232237

233-
const [posY, fallbackY]: VerticalConnectionPos[] =
238+
const [overlayY, fallbackOverlayY]: VerticalConnectionPos[] =
234239
this.menu.positionY === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
235240

241+
let originY = overlayY;
242+
let fallbackOriginY = fallbackOverlayY;
243+
244+
if (!this.menu.overlapTrigger) {
245+
originY = overlayY === 'top' ? 'bottom' : 'top';
246+
fallbackOriginY = fallbackOverlayY === 'top' ? 'bottom' : 'top';
247+
}
248+
236249
return this._overlay.position()
237250
.connectedTo(this._element,
238-
{originX: posX, originY: posY}, {overlayX: posX, overlayY: posY})
251+
{originX: posX, originY: originY}, {overlayX: posX, overlayY: overlayY})
239252
.withFallbackPosition(
240-
{originX: fallbackX, originY: posY},
241-
{overlayX: fallbackX, overlayY: posY})
253+
{originX: fallbackX, originY: originY},
254+
{overlayX: fallbackX, overlayY: overlayY})
242255
.withFallbackPosition(
243-
{originX: posX, originY: fallbackY},
244-
{overlayX: posX, overlayY: fallbackY})
256+
{originX: posX, originY: fallbackOriginY},
257+
{overlayX: posX, overlayY: fallbackOverlayY})
245258
.withFallbackPosition(
246-
{originX: fallbackX, originY: fallbackY},
247-
{overlayX: fallbackX, overlayY: fallbackY});
259+
{originX: fallbackX, originY: fallbackOriginY},
260+
{overlayX: fallbackX, overlayY: fallbackOverlayY});
248261
}
249262

250263
private _cleanUpSubscriptions(): void {

src/lib/menu/menu.spec.ts

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import {TestBed, async} from '@angular/core/testing';
1+
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
22
import {By} from '@angular/platform-browser';
33
import {
44
Component,
55
ElementRef,
66
EventEmitter,
7+
Input,
78
Output,
89
TemplateRef,
910
ViewChild
@@ -18,6 +19,7 @@ import {
1819
import {OverlayContainer} from '../core/overlay/overlay-container';
1920
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
2021
import {Dir, LayoutDirection} from '../core/rtl/dir';
22+
import {extendObject} from '../core/util/object-extend';
2123

2224
describe('MdMenu', () => {
2325
let overlayContainerElement: HTMLElement;
@@ -27,7 +29,7 @@ describe('MdMenu', () => {
2729
dir = 'ltr';
2830
TestBed.configureTestingModule({
2931
imports: [MdMenuModule.forRoot()],
30-
declarations: [SimpleMenu, PositionedMenu, CustomMenuPanel, CustomMenu],
32+
declarations: [SimpleMenu, PositionedMenu, OverlapMenu, CustomMenuPanel, CustomMenu],
3133
providers: [
3234
{provide: OverlayContainer, useFactory: () => {
3335
overlayContainerElement = document.createElement('div');
@@ -256,6 +258,106 @@ describe('MdMenu', () => {
256258
}
257259
});
258260

261+
describe('overlapping trigger', () => {
262+
/**
263+
* This test class is used to create components containing a menu.
264+
* It provides helpers to reposition the trigger, open the menu,
265+
* and access the trigger and overlay positions.
266+
* Additionally it can take any inputs for the menu wrapper component.
267+
*
268+
* Basic usage:
269+
* const subject = new OverlapSubject(MyComponent);
270+
* subject.openMenu();
271+
*/
272+
class OverlapSubject<T extends TestableMenu> {
273+
private readonly fixture: ComponentFixture<T>;
274+
private readonly trigger: any;
275+
276+
constructor(ctor: {new(): T; }, inputs: {[key: string]: any} = {}) {
277+
this.fixture = TestBed.createComponent(ctor);
278+
extendObject(this.fixture.componentInstance, inputs);
279+
this.fixture.detectChanges();
280+
this.trigger = this.fixture.componentInstance.triggerEl.nativeElement;
281+
}
282+
283+
openMenu() {
284+
this.fixture.componentInstance.trigger.openMenu();
285+
this.fixture.detectChanges();
286+
}
287+
288+
updateTriggerStyle(style: any) {
289+
return extendObject(this.trigger.style, style);
290+
}
291+
292+
get overlayRect() {
293+
return this.overlayPane.getBoundingClientRect();
294+
}
295+
296+
get triggerRect() {
297+
return this.trigger.getBoundingClientRect();
298+
}
299+
300+
get menuPanel() {
301+
return overlayContainerElement.querySelector('.md-menu-panel');
302+
}
303+
304+
private get overlayPane() {
305+
return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
306+
}
307+
}
308+
309+
let subject: OverlapSubject<OverlapMenu>;
310+
describe('explicitly overlapping', () => {
311+
beforeEach(() => {
312+
subject = new OverlapSubject(OverlapMenu, {overlapTrigger: true});
313+
});
314+
315+
it('positions the overlay below the trigger', () => {
316+
subject.openMenu();
317+
318+
// Since the menu is overlaying the trigger, the overlay top should be the trigger top.
319+
expect(Math.round(subject.overlayRect.top))
320+
.toBe(Math.round(subject.triggerRect.top),
321+
`Expected menu to open in default "below" position.`);
322+
});
323+
});
324+
325+
describe('not overlapping', () => {
326+
beforeEach(() => {
327+
subject = new OverlapSubject(OverlapMenu, {overlapTrigger: false});
328+
});
329+
330+
it('positions the overlay below the trigger', () => {
331+
subject.openMenu();
332+
333+
// Since the menu is below the trigger, the overlay top should be the trigger bottom.
334+
expect(Math.round(subject.overlayRect.top))
335+
.toBe(Math.round(subject.triggerRect.bottom),
336+
`Expected menu to open directly below the trigger.`);
337+
});
338+
339+
it('supports above position fall back', () => {
340+
// Push trigger to the bottom part of viewport, so it doesn't have space to open
341+
// in its default "below" position below the trigger.
342+
subject.updateTriggerStyle({position: 'relative', top: '650px'});
343+
subject.openMenu();
344+
345+
// Since the menu is above the trigger, the overlay bottom should be the trigger top.
346+
expect(Math.round(subject.overlayRect.bottom))
347+
.toBe(Math.round(subject.triggerRect.top),
348+
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
349+
});
350+
351+
it('repositions the origin to be below, so the menu opens from the trigger', () => {
352+
subject.openMenu();
353+
354+
expect(subject.menuPanel.classList).toContain('md-menu-below');
355+
expect(subject.menuPanel.classList).not.toContain('md-menu-above');
356+
});
357+
358+
});
359+
});
360+
259361
describe('animations', () => {
260362
it('should include the ripple on items by default', () => {
261363
const fixture = TestBed.createComponent(SimpleMenu);
@@ -311,6 +413,23 @@ class PositionedMenu {
311413
@ViewChild('triggerEl') triggerEl: ElementRef;
312414
}
313415

416+
interface TestableMenu {
417+
trigger: MdMenuTrigger;
418+
triggerEl: ElementRef;
419+
}
420+
@Component({
421+
template: `
422+
<button [mdMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
423+
<md-menu [overlapTrigger]="overlapTrigger" #menu="mdMenu">
424+
<button md-menu-item> Not overlapped Content </button>
425+
</md-menu>
426+
`
427+
})
428+
class OverlapMenu implements TestableMenu {
429+
@Input() overlapTrigger: boolean;
430+
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
431+
@ViewChild('triggerEl') triggerEl: ElementRef;
432+
}
314433

315434
@Component({
316435
selector: 'custom-menu',
@@ -325,6 +444,7 @@ class PositionedMenu {
325444
class CustomMenuPanel implements MdMenuPanel {
326445
positionX: MenuPositionX = 'after';
327446
positionY: MenuPositionY = 'below';
447+
overlapTrigger: true;
328448

329449
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
330450
@Output() close = new EventEmitter<void>();

0 commit comments

Comments
 (0)