Skip to content

Commit

Permalink
fix(menu): reposition menu if it would open off screen
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Nov 7, 2016
1 parent a0d85d8 commit d9b5bd5
Show file tree
Hide file tree
Showing 2 changed files with 178 additions and 16 deletions.
39 changes: 30 additions & 9 deletions src/lib/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
TemplatePortal,
ConnectedPositionStrategy,
HorizontalConnectionPos,
VerticalConnectionPos
VerticalConnectionPos,
OriginConnectionPosition,
OverlayConnectionPosition
} from '../core';
import { Subscription } from 'rxjs/Subscription';

Expand Down Expand Up @@ -181,14 +183,33 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
* @returns ConnectedPositionStrategy
*/
private _getPosition(): ConnectedPositionStrategy {
const positionX: HorizontalConnectionPos = this.menu.positionX === 'before' ? 'end' : 'start';
const positionY: VerticalConnectionPos = this.menu.positionY === 'above' ? 'bottom' : 'top';

return this._overlay.position().connectedTo(
this._element,
{originX: positionX, originY: positionY},
{overlayX: positionX, overlayY: positionY}
);
const [posX, fallbackX]: HorizontalConnectionPos[] =
this.menu.positionX === 'before' ? ['end', 'start'] : ['start', 'end'];

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

return this._overlay.position()
.connectedTo(this._element, this._originPos(posX, posY), this._overlayPos(posX, posY))
.withFallbackPosition(
this._originPos(fallbackX, posY), this._overlayPos(fallbackX, posY))
.withFallbackPosition(
this._originPos(posX, fallbackY), this._overlayPos(posX, fallbackY))
.withFallbackPosition(
this._originPos(fallbackX, fallbackY), this._overlayPos(fallbackX, fallbackY));
}


/** Converts the designated point into an OriginConnectionPosition. */
private _originPos(x: HorizontalConnectionPos,
y: VerticalConnectionPos): OriginConnectionPosition {
return {originX: x, originY: y} as OriginConnectionPosition;
}

/** Converts the designated point into an OverlayConnectionPosition. */
private _overlayPos(x: HorizontalConnectionPos,
y: VerticalConnectionPos): OverlayConnectionPosition {
return {overlayX: x, overlayY: y} as OverlayConnectionPosition;
}

// TODO: internal
Expand Down
155 changes: 148 additions & 7 deletions src/lib/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {TestBed, async} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {
Component,
ElementRef,
EventEmitter,
Output,
TemplateRef,
Expand All @@ -15,6 +16,7 @@ import {
MenuPositionY
} from './menu';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';

describe('MdMenu', () => {
let overlayContainerElement: HTMLElement;
Expand All @@ -26,14 +28,23 @@ describe('MdMenu', () => {
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
overlayContainerElement.style.position = 'fixed';
overlayContainerElement.style.top = '0';
overlayContainerElement.style.left = '0';
document.body.appendChild(overlayContainerElement);
return {getContainerElement: () => overlayContainerElement};
}}
}},
{provide: ViewportRuler, useClass: FakeViewportRuler}
]
});

TestBed.compileComponents();
}));

afterEach(() => {
document.body.removeChild(overlayContainerElement);
});

it('should open the menu as an idempotent operation', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
Expand All @@ -42,8 +53,8 @@ describe('MdMenu', () => {
fixture.componentInstance.trigger.openMenu();
fixture.componentInstance.trigger.openMenu();

expect(overlayContainerElement.textContent).toContain('Simple Content');
expect(overlayContainerElement.textContent).toContain('Disabled Content');
expect(overlayContainerElement.textContent).toContain('Item');
expect(overlayContainerElement.textContent).toContain('Disabled');
}).not.toThrowError();
});

Expand Down Expand Up @@ -110,6 +121,123 @@ describe('MdMenu', () => {
expect(panel.classList).not.toContain('md-menu-below');
});

describe('fallback positions', () => {

it('should fall back to "before" mode if "after" mode would not fit on screen', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
const trigger = fixture.componentInstance.triggerEl.nativeElement;

// Push trigger to the right side of viewport, so it doesn't have space to open
// in its default "after" position on the right side.
trigger.style.position = 'relative';
trigger.style.left = '900px';

fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
const triggerRect = trigger.getBoundingClientRect();
const overlayRect = overlayPane.getBoundingClientRect();

// In "before" position, the right sides of the overlay and the origin are aligned.
// To find the overlay left, subtract the menu width from the origin's right side.
const expectedLeft = triggerRect.right - overlayRect.width;
expect(overlayRect.left.toFixed(2))
.toEqual(expectedLeft.toFixed(2),
`Expected menu to open in "before" position if "after" position wouldn't fit.`);

// The y-position of the overlay should be unaffected, as it can already fit vertically
expect(overlayRect.top.toFixed(2))
.toEqual(triggerRect.top.toFixed(2),
`Expected menu top position to be unchanged if it can fit in the viewport.`);
});

it('should fall back to "above" mode if "below" mode would not fit on screen', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
const trigger = fixture.componentInstance.triggerEl.nativeElement;

// Push trigger to the bottom part of viewport, so it doesn't have space to open
// in its default "below" position below the trigger.
trigger.style.position = 'relative';
trigger.style.top = '600px';

fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
const triggerRect = trigger.getBoundingClientRect();
const overlayRect = overlayPane.getBoundingClientRect();

// In "above" position, the bottom edges of the overlay and the origin are aligned.
// To find the overlay top, subtract the menu height from the origin's bottom edge.
const expectedTop = triggerRect.bottom - overlayRect.height;
expect(overlayRect.top.toFixed(2))
.toEqual(expectedTop.toFixed(2),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);

// The x-position of the overlay should be unaffected, as it can already fit horizontally
expect(overlayRect.left.toFixed(2))
.toEqual(triggerRect.left.toFixed(2),
`Expected menu x position to be unchanged if it can fit in the viewport.`);
});

it('should re-position menu on both axes if both defaults would not fit', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
const trigger = fixture.componentInstance.triggerEl.nativeElement;

// push trigger to the bottom, right part of viewport, so it doesn't have space to open
// in its default "after below" position.
trigger.style.position = 'relative';
trigger.style.left = '900px';
trigger.style.top = '600px';

fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
const triggerRect = trigger.getBoundingClientRect();
const overlayRect = overlayPane.getBoundingClientRect();

const expectedLeft = triggerRect.right - overlayRect.width;
const expectedTop = triggerRect.bottom - overlayRect.height;

expect(overlayRect.left.toFixed(2))
.toEqual(expectedLeft.toFixed(2),
`Expected menu to open in "before" position if "after" position wouldn't fit.`);

expect(overlayRect.top.toFixed(2))
.toEqual(expectedTop.toFixed(2),
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
});

it('should re-position a menu with custom position set', () => {
const fixture = TestBed.createComponent(PositionedMenu);
fixture.detectChanges();
const trigger = fixture.componentInstance.triggerEl.nativeElement;

fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
const triggerRect = trigger.getBoundingClientRect();
const overlayRect = overlayPane.getBoundingClientRect();

// As designated "before" position won't fit on screen, the menu should fall back
// to "after" mode, where the left sides of the overlay and trigger are aligned.
expect(overlayRect.left.toFixed(2))
.toEqual(triggerRect.left.toFixed(2),
`Expected menu to open in "after" position if "before" position wouldn't fit.`);

// As designated "above" position won't fit on screen, the menu should fall back
// to "below" mode, where the top edges of the overlay and trigger are aligned.
expect(overlayRect.top.toFixed(2))
.toEqual(triggerRect.top.toFixed(2),
`Expected menu to open in "below" position if "above" position wouldn't fit.`);
});

});



});

describe('animations', () => {
Expand Down Expand Up @@ -142,27 +270,29 @@ describe('MdMenu', () => {

@Component({
template: `
<button [md-menu-trigger-for]="menu">Toggle menu</button>
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
<md-menu #menu="mdMenu">
<button md-menu-item> Simple Content </button>
<button md-menu-item disabled> Disabled Content </button>
<button md-menu-item> Item </button>
<button md-menu-item disabled> Disabled </button>
</md-menu>
`
})
class SimpleMenu {
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
@ViewChild('triggerEl') triggerEl: ElementRef;
}

@Component({
template: `
<button [md-menu-trigger-for]="menu">Toggle menu</button>
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
<md-menu x-position="before" y-position="above" #menu="mdMenu">
<button md-menu-item> Positioned Content </button>
</md-menu>
`
})
class PositionedMenu {
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
@ViewChild('triggerEl') triggerEl: ElementRef;
}


Expand Down Expand Up @@ -195,3 +325,14 @@ class CustomMenuPanel implements MdMenuPanel {
class CustomMenu {
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
}

class FakeViewportRuler {
getViewportRect() {
return {
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
};
}
getViewportScrollPosition() {
return {top: 0, left: 0};
}
}

0 comments on commit d9b5bd5

Please sign in to comment.