Skip to content

Commit b93d246

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

File tree

3 files changed

+179
-16
lines changed

3 files changed

+179
-16
lines changed

src/lib/core/overlay/position/connected-position-strategy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export class ConnectedPositionStrategy implements PositionStrategy {
203203
overlayRect: ClientRect,
204204
viewportRect: ClientRect): boolean {
205205

206+
console.log(overlayPoint.x, viewportRect.left, overlayRect.width);
206207
// TODO(jelbourn): probably also want some space between overlay edge and viewport edge.
207208
return overlayPoint.x >= viewportRect.left &&
208209
overlayPoint.x + overlayRect.width <= viewportRect.right &&

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

115243
describe('animations', () => {
@@ -142,27 +270,29 @@ describe('MdMenu', () => {
142270

143271
@Component({
144272
template: `
145-
<button [md-menu-trigger-for]="menu">Toggle menu</button>
273+
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
146274
<md-menu #menu="mdMenu">
147-
<button md-menu-item> Simple Content </button>
148-
<button md-menu-item disabled> Disabled Content </button>
275+
<button md-menu-item> Item </button>
276+
<button md-menu-item disabled> Disabled </button>
149277
</md-menu>
150278
`
151279
})
152280
class SimpleMenu {
153281
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
282+
@ViewChild('triggerEl') triggerEl: ElementRef;
154283
}
155284

156285
@Component({
157286
template: `
158-
<button [md-menu-trigger-for]="menu">Toggle menu</button>
287+
<button [md-menu-trigger-for]="menu" #triggerEl>Toggle menu</button>
159288
<md-menu x-position="before" y-position="above" #menu="mdMenu">
160289
<button md-menu-item> Positioned Content </button>
161290
</md-menu>
162291
`
163292
})
164293
class PositionedMenu {
165294
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
295+
@ViewChild('triggerEl') triggerEl: ElementRef;
166296
}
167297

168298

@@ -195,3 +325,14 @@ class CustomMenuPanel implements MdMenuPanel {
195325
class CustomMenu {
196326
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
197327
}
328+
329+
class FakeViewportRuler {
330+
getViewportRect() {
331+
return {
332+
left: 0, top: 0, width: 1014, height: 686, bottom: 686, right: 1014
333+
};
334+
}
335+
getViewportScrollPosition() {
336+
return {top: 0, left: 0};
337+
}
338+
}

0 commit comments

Comments
 (0)