Skip to content

Commit 1ce8d3e

Browse files
committed
fix(menu): reposition menu if it would open off screen
1 parent a0d85d8 commit 1ce8d3e

File tree

2 files changed

+172
-16
lines changed

2 files changed

+172
-16
lines changed

src/lib/menu/menu-trigger.ts

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import {
2020
TemplatePortal,
2121
ConnectedPositionStrategy,
2222
HorizontalConnectionPos,
23-
VerticalConnectionPos
23+
VerticalConnectionPos,
24+
OriginConnectionPosition,
25+
OverlayConnectionPosition
2426
} from '../core';
2527
import { Subscription } from 'rxjs/Subscription';
2628

@@ -181,14 +183,33 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
181183
* @returns ConnectedPositionStrategy
182184
*/
183185
private _getPosition(): ConnectedPositionStrategy {
184-
const positionX: HorizontalConnectionPos = this.menu.positionX === 'before' ? 'end' : 'start';
185-
const positionY: VerticalConnectionPos = this.menu.positionY === 'above' ? 'bottom' : 'top';
186-
187-
return this._overlay.position().connectedTo(
188-
this._element,
189-
{originX: positionX, originY: positionY},
190-
{overlayX: positionX, overlayY: positionY}
191-
);
186+
const [posX, fallbackX]: HorizontalConnectionPos[] =
187+
this.menu.positionX === 'before' ? ['end', 'start'] : ['start', 'end'];
188+
189+
const [posY, fallbackY]: VerticalConnectionPos[] =
190+
this.menu.positionY === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
191+
192+
return this._overlay.position()
193+
.connectedTo(this._element, this._originPos(posX, posY), this._overlayPos(posX, posY))
194+
.withFallbackPosition(
195+
this._originPos(fallbackX, posY), this._overlayPos(fallbackX, posY))
196+
.withFallbackPosition(
197+
this._originPos(posX, fallbackY), this._overlayPos(posX, fallbackY))
198+
.withFallbackPosition(
199+
this._originPos(fallbackX, fallbackY), this._overlayPos(fallbackX, fallbackY));
200+
}
201+
202+
203+
/** Converts the designated point into an OriginConnectionPosition. */
204+
private _originPos(x: HorizontalConnectionPos,
205+
y: VerticalConnectionPos): OriginConnectionPosition {
206+
return {originX: x, originY: y} as OriginConnectionPosition;
207+
}
208+
209+
/** Converts the designated point into an OverlayConnectionPosition. */
210+
private _overlayPos(x: HorizontalConnectionPos,
211+
y: VerticalConnectionPos): OverlayConnectionPosition {
212+
return {overlayX: x, overlayY: y} as OverlayConnectionPosition;
192213
}
193214

194215
// TODO: internal

src/lib/menu/menu.spec.ts

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {TestBed, async} from '@angular/core/testing';
22
import {By} from '@angular/platform-browser';
33
import {
44
Component,
5+
ElementRef,
56
EventEmitter,
67
Output,
78
TemplateRef,
@@ -15,6 +16,7 @@ import {
1516
MenuPositionY
1617
} from './menu';
1718
import {OverlayContainer} from '../core/overlay/overlay-container';
19+
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
1820

1921
describe('MdMenu', () => {
2022
let overlayContainerElement: HTMLElement;
@@ -26,14 +28,23 @@ describe('MdMenu', () => {
2628
providers: [
2729
{provide: OverlayContainer, useFactory: () => {
2830
overlayContainerElement = document.createElement('div');
31+
overlayContainerElement.style.position = 'fixed';
32+
overlayContainerElement.style.top = '0';
33+
overlayContainerElement.style.left = '0';
34+
document.body.appendChild(overlayContainerElement);
2935
return {getContainerElement: () => overlayContainerElement};
30-
}}
36+
}},
37+
{provide: ViewportRuler, useClass: FakeViewportRuler}
3138
]
3239
});
3340

3441
TestBed.compileComponents();
3542
}));
3643

44+
afterEach(() => {
45+
document.body.removeChild(overlayContainerElement);
46+
});
47+
3748
it('should open the menu as an idempotent operation', () => {
3849
const fixture = TestBed.createComponent(SimpleMenu);
3950
fixture.detectChanges();
@@ -42,8 +53,8 @@ describe('MdMenu', () => {
4253
fixture.componentInstance.trigger.openMenu();
4354
fixture.componentInstance.trigger.openMenu();
4455

45-
expect(overlayContainerElement.textContent).toContain('Simple Content');
46-
expect(overlayContainerElement.textContent).toContain('Disabled Content');
56+
expect(overlayContainerElement.textContent).toContain('Item');
57+
expect(overlayContainerElement.textContent).toContain('Disabled');
4758
}).not.toThrowError();
4859
});
4960

@@ -110,6 +121,117 @@ describe('MdMenu', () => {
110121
expect(panel.classList).not.toContain('md-menu-below');
111122
});
112123

124+
describe('fallback positions', () => {
125+
126+
it('should fall back to "before" mode if "after" mode would not fit on screen', () => {
127+
const fixture = TestBed.createComponent(SimpleMenu);
128+
fixture.detectChanges();
129+
const trigger = fixture.componentInstance.triggerEl.nativeElement;
130+
131+
// Push trigger to the right side of viewport, so it doesn't have space to open
132+
// in its default "after" position on the right side.
133+
trigger.style.marginLeft = '900px';
134+
135+
fixture.componentInstance.trigger.openMenu();
136+
fixture.detectChanges();
137+
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
138+
const triggerRect = trigger.getBoundingClientRect();
139+
140+
// In "before" position, the right sides of the overlay and the origin are aligned.
141+
// To find the overlay left, subtract the menu width (112) from the origin's right side.
142+
const expectedLeft = triggerRect.right - 112;
143+
expect(overlayPane.getBoundingClientRect().left)
144+
.toEqual(expectedLeft,
145+
`Expected menu to open in "before" position if "after" position wouldn't fit.`);
146+
147+
// The y-position of the overlay should be unaffected, as it can already fit vertically
148+
expect(overlayPane.getBoundingClientRect().top)
149+
.toEqual(triggerRect.top,
150+
`Expected menu top position to be unchanged if it can fit in the viewport.`);
151+
});
152+
153+
it('should fall back to "above" mode if "below" mode would not fit on screen', () => {
154+
const fixture = TestBed.createComponent(SimpleMenu);
155+
fixture.detectChanges();
156+
const trigger = fixture.componentInstance.triggerEl.nativeElement;
157+
158+
// Push trigger to the bottom part of viewport, so it doesn't have space to open
159+
// in its default "below" position below the trigger.
160+
trigger.style.marginTop = '600px';
161+
162+
fixture.componentInstance.trigger.openMenu();
163+
fixture.detectChanges();
164+
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
165+
const triggerRect = trigger.getBoundingClientRect();
166+
167+
// In "above" position, the bottom edges of the overlay and the origin are aligned.
168+
// To find the overlay top, subtract the menu height from the origin's bottom edge.
169+
// Menu height = 48 per item * 2 + 16px padding = 112px
170+
const expectedTop = triggerRect.bottom - 112;
171+
expect(overlayPane.getBoundingClientRect().top)
172+
.toEqual(expectedTop,
173+
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
174+
175+
// The x-position of the overlay should be unaffected, as it can already fit horizontally
176+
expect(overlayPane.getBoundingClientRect().left)
177+
.toEqual(triggerRect.left,
178+
`Expected menu x position to be unchanged if it can fit in the viewport.`);
179+
});
180+
181+
it('should re-position menu on both axes if both defaults would not fit', () => {
182+
const fixture = TestBed.createComponent(SimpleMenu);
183+
fixture.detectChanges();
184+
const trigger = fixture.componentInstance.triggerEl.nativeElement;
185+
186+
// push trigger to the bottom, right part of viewport, so it doesn't have space to open
187+
// in its default "after below" position.
188+
trigger.style.marginLeft = '900px';
189+
trigger.style.marginTop = '600px';
190+
191+
fixture.componentInstance.trigger.openMenu();
192+
fixture.detectChanges();
193+
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
194+
const triggerRect = trigger.getBoundingClientRect();
195+
196+
const expectedTop = triggerRect.bottom - 112;
197+
const expectedLeft = triggerRect.right - 112;
198+
199+
expect(overlayPane.getBoundingClientRect().left)
200+
.toEqual(expectedLeft,
201+
`Expected menu to open in "before" position if "after" position wouldn't fit.`);
202+
203+
expect(overlayPane.getBoundingClientRect().top)
204+
.toEqual(expectedTop,
205+
`Expected menu to open in "above" position if "below" position wouldn't fit.`);
206+
});
207+
208+
it('should re-position a menu with custom position set', () => {
209+
const fixture = TestBed.createComponent(PositionedMenu);
210+
fixture.detectChanges();
211+
const trigger = fixture.componentInstance.triggerEl.nativeElement;
212+
213+
fixture.componentInstance.trigger.openMenu();
214+
fixture.detectChanges();
215+
const overlayPane = overlayContainerElement.children[0] as HTMLElement;
216+
const triggerRect = trigger.getBoundingClientRect();
217+
218+
// As designated "before" position won't fit on screen, the menu should fall back
219+
// to "after" mode, where the left sides of the overlay and trigger are aligned.
220+
expect(overlayPane.getBoundingClientRect().left)
221+
.toEqual(triggerRect.left,
222+
`Expected menu to open in "after" position if "before" position wouldn't fit.`);
223+
224+
// As designated "above" position won't fit on screen, the menu should fall back
225+
// to "below" mode, where the top edges of the overlay and trigger are aligned.
226+
expect(overlayPane.getBoundingClientRect().top)
227+
.toEqual(triggerRect.top,
228+
`Expected menu to open in "below" position if "above" position wouldn't fit.`);
229+
});
230+
231+
});
232+
233+
234+
113235
});
114236

115237
describe('animations', () => {
@@ -142,27 +264,29 @@ describe('MdMenu', () => {
142264

143265
@Component({
144266
template: `
145-
<button [md-menu-trigger-for]="menu">Toggle menu</button>
267+
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
146268
<md-menu #menu="mdMenu">
147-
<button md-menu-item> Simple Content </button>
148-
<button md-menu-item disabled> Disabled Content </button>
269+
<button md-menu-item> Item </button>
270+
<button md-menu-item disabled> Disabled </button>
149271
</md-menu>
150272
`
151273
})
152274
class SimpleMenu {
153275
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
276+
@ViewChild('triggerEl') triggerEl: ElementRef;
154277
}
155278

156279
@Component({
157280
template: `
158-
<button [md-menu-trigger-for]="menu">Toggle menu</button>
281+
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
159282
<md-menu x-position="before" y-position="above" #menu="mdMenu">
160283
<button md-menu-item> Positioned Content </button>
161284
</md-menu>
162285
`
163286
})
164287
class PositionedMenu {
165288
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
289+
@ViewChild('triggerEl') triggerEl: ElementRef;
166290
}
167291

168292

@@ -195,3 +319,14 @@ class CustomMenuPanel implements MdMenuPanel {
195319
class CustomMenu {
196320
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
197321
}
322+
323+
class FakeViewportRuler {
324+
getViewportRect() {
325+
return {
326+
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
327+
};
328+
}
329+
getViewportScrollPosition() {
330+
return {top: 0, left: 0};
331+
}
332+
}

0 commit comments

Comments
 (0)