Skip to content

feat(menu): Added ability to show the menu overlay around the menu trigger #1771

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 20, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions src/demo-app/menu/menu-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,54 @@
</md-menu>
</div>
</div>

<div class="demo-menu">
<div class="menu-section">
<p>overlapTrigger: false</p>

<md-toolbar>
<button md-icon-button [md-menu-trigger-for]="menuOverlay">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>

<md-menu [overlapTrigger]="false" #menuOverlay="mdMenu">
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
{{ item.text }}
</button>
</md-menu>
</div>
<div class="menu-section">
<p>
Position x: before, overlapTrigger: false
</p>
<md-toolbar class="end-icon">
<button md-icon-button [md-menu-trigger-for]="posXMenuOverlay">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>

<md-menu x-position="before" [overlapTrigger]="false" #posXMenuOverlay="mdMenu" class="before">
<button md-menu-item *ngFor="let item of iconItems" [disabled]="item.disabled">
<md-icon>{{ item.icon }}</md-icon>
{{ item.text }}
</button>
</md-menu>
</div>
<div class="menu-section">
<p>
Position y: above, overlapTrigger: false
</p>
<md-toolbar>
<button md-icon-button [md-menu-trigger-for]="posYMenuOverlay">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>

<md-menu y-position="above" [overlapTrigger]="false" #posYMenuOverlay="mdMenu">
<button md-menu-item *ngFor="let item of items" [disabled]="item.disabled">
{{ item.text }}
</button>
</md-menu>
</div>
</div>
3 changes: 2 additions & 1 deletion src/lib/menu/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ Menus support displaying `md-icon` elements before the menu item text.

### Customizing menu position

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


### Keyboard interaction
Expand Down
4 changes: 3 additions & 1 deletion src/lib/menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ Output:
### Customizing menu position

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

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

### Trigger Programmatic API

Expand Down
1 change: 1 addition & 0 deletions src/lib/menu/menu-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {

@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@ContentChildren(MdMenuItem) items: QueryList<MdMenuItem>;
@Input() overlapTrigger = true;

constructor(@Attribute('x-position') posX: MenuPositionX,
@Attribute('y-position') posY: MenuPositionY) {
Expand Down
1 change: 1 addition & 0 deletions src/lib/menu/menu-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {MenuPositionX, MenuPositionY} from './menu-positions';
export interface MdMenuPanel {
positionX: MenuPositionX;
positionY: MenuPositionY;
overlapTrigger: boolean;
templateRef: TemplateRef<any>;
close: EventEmitter<void>;
focusFirstItem: () => void;
Expand Down
31 changes: 22 additions & 9 deletions src/lib/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,12 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
private _subscribeToPositions(position: ConnectedPositionStrategy): void {
this._positionSubscription = position.onPositionChange.subscribe((change) => {
const posX: MenuPositionX = change.connectionPair.originX === 'start' ? 'after' : 'before';
const posY: MenuPositionY = change.connectionPair.originY === 'top' ? 'below' : 'above';
let posY: MenuPositionY = change.connectionPair.originY === 'top' ? 'below' : 'above';

if (!this.menu.overlapTrigger) {
posY = posY === 'below' ? 'above' : 'below';
}

this.menu.setPositionClasses(posX, posY);
});
}
Expand All @@ -230,21 +235,29 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
const [posX, fallbackX]: HorizontalConnectionPos[] =
this.menu.positionX === 'before' ? ['end', 'start'] : ['start', 'end'];

const [posY, fallbackY]: VerticalConnectionPos[] =
const [overlayY, fallbackOverlayY]: VerticalConnectionPos[] =
this.menu.positionY === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];

let originY = overlayY;
let fallbackOriginY = fallbackOverlayY;

if (!this.menu.overlapTrigger) {
originY = overlayY === 'top' ? 'bottom' : 'top';
fallbackOriginY = fallbackOverlayY === 'top' ? 'bottom' : 'top';
Copy link
Contributor

@kara kara Jan 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning to our previous discussion, this logic is actually backwards. The originY should match the overlayY when the menu is overlapping. When it's not overlapping, the top of the overlay should hit the bottom of the trigger, etc.

The reason that this happens to work in the demo and the tests is because overlapTrigger is a string. Strings are truthy, so if you set overlapTrigger to anything (including "false"), this.menu.overlapTrigger will be truthy and execute this overlap block. When you don't set the attribute (like in the original tests/demos), this.menu.overlapTrigger is null, so it doesn't default to true and doesn't hit this condition. If you set overlapTrigger="true" in the demo, you'll see that all values for overlapTrigger will make the menu not overlap, including "true".

I'd coerce this.menu.overlapTrigger to a boolean in menu-directive.ts, and invert the logic here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely correct. The test of an explicit overlapTrigger = true is a good suggestion and exposes this in a test. Thanks for pushing back. Fixed.

}

return this._overlay.position()
.connectedTo(this._element,
{originX: posX, originY: posY}, {overlayX: posX, overlayY: posY})
{originX: posX, originY: originY}, {overlayX: posX, overlayY: overlayY})
.withFallbackPosition(
{originX: fallbackX, originY: posY},
{overlayX: fallbackX, overlayY: posY})
{originX: fallbackX, originY: originY},
{overlayX: fallbackX, overlayY: overlayY})
.withFallbackPosition(
{originX: posX, originY: fallbackY},
{overlayX: posX, overlayY: fallbackY})
{originX: posX, originY: fallbackOriginY},
{overlayX: posX, overlayY: fallbackOverlayY})
.withFallbackPosition(
{originX: fallbackX, originY: fallbackY},
{overlayX: fallbackX, overlayY: fallbackY});
{originX: fallbackX, originY: fallbackOriginY},
{overlayX: fallbackX, overlayY: fallbackOverlayY});
}

private _cleanUpSubscriptions(): void {
Expand Down
124 changes: 122 additions & 2 deletions src/lib/menu/menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {TestBed, async} from '@angular/core/testing';
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {
Component,
ElementRef,
EventEmitter,
Input,
Output,
TemplateRef,
ViewChild
Expand All @@ -18,6 +19,7 @@ import {
import {OverlayContainer} from '../core/overlay/overlay-container';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
import {Dir, LayoutDirection} from '../core/rtl/dir';
import {extendObject} from '../core/util/object-extend';

describe('MdMenu', () => {
let overlayContainerElement: HTMLElement;
Expand All @@ -27,7 +29,7 @@ describe('MdMenu', () => {
dir = 'ltr';
TestBed.configureTestingModule({
imports: [MdMenuModule.forRoot()],
declarations: [SimpleMenu, PositionedMenu, CustomMenuPanel, CustomMenu],
declarations: [SimpleMenu, PositionedMenu, OverlapMenu, CustomMenuPanel, CustomMenu],
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
Expand Down Expand Up @@ -256,6 +258,106 @@ describe('MdMenu', () => {
}
});

describe('overlapping trigger', () => {
/**
* This test class is used to create components containing a menu.
* It provides helpers to reposition the trigger, open the menu,
* and access the trigger and overlay positions.
* Additionally it can take any inputs for the menu wrapper component.
*
* Basic usage:
* const subject = new OverlapSubject(MyComponent);
* subject.openMenu();
*/
class OverlapSubject<T extends TestableMenu> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add comment describing class?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

private readonly fixture: ComponentFixture<T>;
private readonly trigger: any;

constructor(ctor: {new(): T; }, inputs: {[key: string]: any} = {}) {
this.fixture = TestBed.createComponent(ctor);
extendObject(this.fixture.componentInstance, inputs);
this.fixture.detectChanges();
this.trigger = this.fixture.componentInstance.triggerEl.nativeElement;
}

openMenu() {
this.fixture.componentInstance.trigger.openMenu();
this.fixture.detectChanges();
}

updateTriggerStyle(style: any) {
return extendObject(this.trigger.style, style);
}

get overlayRect() {
return this.overlayPane.getBoundingClientRect();
}

get triggerRect() {
return this.trigger.getBoundingClientRect();
}

get menuPanel() {
return overlayContainerElement.querySelector('.md-menu-panel');
}

private get overlayPane() {
return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
}
}

let subject: OverlapSubject<OverlapMenu>;
describe('explicitly overlapping', () => {
beforeEach(() => {
subject = new OverlapSubject(OverlapMenu, {overlapTrigger: true});
});

it('positions the overlay below the trigger', () => {
subject.openMenu();

// Since the menu is overlaying the trigger, the overlay top should be the trigger top.
expect(Math.round(subject.overlayRect.top))
.toBe(Math.round(subject.triggerRect.top),
`Expected menu to open in default "below" position.`);
});
});

describe('not overlapping', () => {
beforeEach(() => {
subject = new OverlapSubject(OverlapMenu, {overlapTrigger: false});
});

it('positions the overlay below the trigger', () => {
subject.openMenu();

// Since the menu is below the trigger, the overlay top should be the trigger bottom.
expect(Math.round(subject.overlayRect.top))
.toBe(Math.round(subject.triggerRect.bottom),
`Expected menu to open directly below the trigger.`);
});

it('supports above position fall back', () => {
// Push trigger to the bottom part of viewport, so it doesn't have space to open
// in its default "below" position below the trigger.
subject.updateTriggerStyle({position: 'relative', top: '650px'});
subject.openMenu();

// Since the menu is above the trigger, the overlay bottom should be the trigger top.
expect(Math.round(subject.overlayRect.bottom))
.toBe(Math.round(subject.triggerRect.top),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
});

it('repositions the origin to be below, so the menu opens from the trigger', () => {
subject.openMenu();

expect(subject.menuPanel.classList).toContain('md-menu-below');
expect(subject.menuPanel.classList).not.toContain('md-menu-above');
});

});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a test for when overlapTrigger="true"?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.


describe('animations', () => {
it('should include the ripple on items by default', () => {
const fixture = TestBed.createComponent(SimpleMenu);
Expand Down Expand Up @@ -311,6 +413,23 @@ class PositionedMenu {
@ViewChild('triggerEl') triggerEl: ElementRef;
}

interface TestableMenu {
trigger: MdMenuTrigger;
triggerEl: ElementRef;
}
@Component({
template: `
<button [mdMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
<md-menu [overlapTrigger]="overlapTrigger" #menu="mdMenu">
<button md-menu-item> Not overlapped Content </button>
</md-menu>
`
})
class OverlapMenu implements TestableMenu {
@Input() overlapTrigger: boolean;
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't look like you're using this. Remove?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. Totally missed that!

@ViewChild('triggerEl') triggerEl: ElementRef;
}

@Component({
selector: 'custom-menu',
Expand All @@ -325,6 +444,7 @@ class PositionedMenu {
class CustomMenuPanel implements MdMenuPanel {
positionX: MenuPositionX = 'after';
positionY: MenuPositionY = 'below';
overlapTrigger: true;

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