diff --git a/src/demo-app/menu/menu-demo.html b/src/demo-app/menu/menu-demo.html index 1d818adf3e7f..d43fa31347ad 100644 --- a/src/demo-app/menu/menu-demo.html +++ b/src/demo-app/menu/menu-demo.html @@ -62,3 +62,54 @@ + +
+ + + +
diff --git a/src/lib/menu/OVERVIEW.md b/src/lib/menu/OVERVIEW.md index 44650a524bbc..d78a0fba903e 100644 --- a/src/lib/menu/OVERVIEW.md +++ b/src/lib/menu/OVERVIEW.md @@ -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 diff --git a/src/lib/menu/README.md b/src/lib/menu/README.md index f6d8f210395e..64080625654e 100644 --- a/src/lib/menu/README.md +++ b/src/lib/menu/README.md @@ -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 @@ -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 diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index 765fb56030b0..f2c57d4bdb34 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -52,6 +52,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { @ViewChild(TemplateRef) templateRef: TemplateRef; @ContentChildren(MdMenuItem) items: QueryList; + @Input('overlapTrigger') overlapTrigger = true; constructor(@Attribute('x-position') posX: MenuPositionX, @Attribute('y-position') posY: MenuPositionY) { diff --git a/src/lib/menu/menu-panel.ts b/src/lib/menu/menu-panel.ts index ac92973e99c1..dd3728cd2e7b 100644 --- a/src/lib/menu/menu-panel.ts +++ b/src/lib/menu/menu-panel.ts @@ -4,6 +4,7 @@ import {MenuPositionX, MenuPositionY} from './menu-positions'; export interface MdMenuPanel { positionX: MenuPositionX; positionY: MenuPositionY; + overlapTrigger: boolean; templateRef: TemplateRef; close: EventEmitter; focusFirstItem: () => void; diff --git a/src/lib/menu/menu-trigger.ts b/src/lib/menu/menu-trigger.ts index 1b0a80216192..d295603a2a58 100644 --- a/src/lib/menu/menu-trigger.ts +++ b/src/lib/menu/menu-trigger.ts @@ -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); }); } @@ -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'; + } + 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 { diff --git a/src/lib/menu/menu.spec.ts b/src/lib/menu/menu.spec.ts index e75f5321c834..942d0e2d05ae 100644 --- a/src/lib/menu/menu.spec.ts +++ b/src/lib/menu/menu.spec.ts @@ -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 @@ -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; @@ -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'); @@ -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 { + private readonly fixture: ComponentFixture; + 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; + 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'); + }); + + }); + }); + describe('animations', () => { it('should include the ripple on items by default', () => { const fixture = TestBed.createComponent(SimpleMenu); @@ -311,6 +413,23 @@ class PositionedMenu { @ViewChild('triggerEl') triggerEl: ElementRef; } +interface TestableMenu { + trigger: MdMenuTrigger; + triggerEl: ElementRef; +} +@Component({ + template: ` + + + + + ` +}) +class OverlapMenu implements TestableMenu { + @Input('overlapTrigger') overlapTrigger: boolean; + @ViewChild(MdMenuTrigger) trigger: MdMenuTrigger; + @ViewChild('triggerEl') triggerEl: ElementRef; +} @Component({ selector: 'custom-menu', @@ -325,6 +444,7 @@ class PositionedMenu { class CustomMenuPanel implements MdMenuPanel { positionX: MenuPositionX = 'after'; positionY: MenuPositionY = 'below'; + overlapTrigger: true; @ViewChild(TemplateRef) templateRef: TemplateRef; @Output() close = new EventEmitter();