+
\ No newline at end of file
diff --git a/src/lib/autocomplete/autocomplete.scss b/src/lib/autocomplete/autocomplete.scss
index d6c9b0162d35..7dbf3071febc 100644
--- a/src/lib/autocomplete/autocomplete.scss
+++ b/src/lib/autocomplete/autocomplete.scss
@@ -1,5 +1,28 @@
@import '../core/style/menu-common';
+/**
+ * The max-height of the panel, currently matching md-select value.
+ * TODO: Check value with MD team.
+ */
+$md-autocomplete-panel-max-height: 256px !default;
+
+/** When in "below" position, the panel needs a slight y-offset to ensure the input underline displays. */
+$md-autocomplete-panel-below-offset: 6px !default;
+
+/** When in "above" position, the panel needs a larger y-offset to ensure the label has room to display. */
+$md-autocomplete-panel-above-offset: -24px !default;
+
.md-autocomplete-panel {
@include md-menu-base();
+
+ max-height: $md-autocomplete-panel-max-height;
+ position: relative;
+
+ &.md-autocomplete-panel-below {
+ top: $md-autocomplete-panel-below-offset;
+ }
+
+ &.md-autocomplete-panel-above {
+ top: $md-autocomplete-panel-above-offset;
+ }
}
\ No newline at end of file
diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts
index a431cfce3092..37411c60aaf8 100644
--- a/src/lib/autocomplete/autocomplete.spec.ts
+++ b/src/lib/autocomplete/autocomplete.spec.ts
@@ -9,6 +9,7 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms';
import {Subscription} from 'rxjs/Subscription';
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
import {MdOption} from '../core/option/option';
+import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
describe('MdAutocomplete', () => {
let overlayContainerElement: HTMLElement;
@@ -35,6 +36,7 @@ describe('MdAutocomplete', () => {
{provide: Dir, useFactory: () => {
return {value: dir};
}},
+ {provide: ViewportRuler, useClass: FakeViewportRuler}
]
});
@@ -392,8 +394,8 @@ describe('MdAutocomplete', () => {
});
describe('aria', () => {
- let fixture: ComponentFixture
;
- let input: HTMLInputElement;
+ let fixture: ComponentFixture;
+ let input: HTMLInputElement;
beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
@@ -477,6 +479,77 @@ describe('MdAutocomplete', () => {
expect(input.getAttribute('aria-owns'))
.toEqual(panel.getAttribute('id'), 'Expected aria-owns to match attached autocomplete.');
+
+ });
+
+ });
+
+ describe('Fallback positions', () => {
+ let fixture: ComponentFixture;
+ let input: HTMLInputElement;
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SimpleAutocomplete);
+ fixture.detectChanges();
+
+ input = fixture.debugElement.query(By.css('input')).nativeElement;
+ });
+
+ it('should use below positioning by default', () => {
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ const inputBottom = input.getBoundingClientRect().bottom;
+ const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
+ const panelTop = panel.getBoundingClientRect().top;
+
+ // Panel is offset by 6px in styles so that the underline has room to display.
+ expect((inputBottom + 6).toFixed(2))
+ .toEqual(panelTop.toFixed(2), `Expected panel top to match input bottom by default.`);
+ expect(fixture.componentInstance.trigger.autocomplete.positionY)
+ .toEqual('below', `Expected autocomplete positionY to default to below.`);
+ });
+
+ it('should fall back to above position if panel cannot fit below', () => {
+ // Push the autocomplete trigger down so it won't have room to open "below"
+ input.style.top = '400px';
+ input.style.position = 'relative';
+
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ const inputTop = input.getBoundingClientRect().top;
+ const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
+ const panelBottom = panel.getBoundingClientRect().bottom;
+
+ // Panel is offset by 24px in styles so that the label has room to display.
+ expect((inputTop - 24).toFixed(2))
+ .toEqual(panelBottom.toFixed(2), `Expected panel to fall back to above position.`);
+ expect(fixture.componentInstance.trigger.autocomplete.positionY)
+ .toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
+ });
+
+ it('should align panel properly when filtering in "above" position', () => {
+ // Push the autocomplete trigger down so it won't have room to open "below"
+ input.style.top = '400px';
+ input.style.position = 'relative';
+
+ fixture.componentInstance.trigger.openPanel();
+ fixture.detectChanges();
+
+ input.value = 'f';
+ dispatchEvent('input', input);
+ fixture.detectChanges();
+
+ const inputTop = input.getBoundingClientRect().top;
+ const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
+ const panelBottom = panel.getBoundingClientRect().bottom;
+
+ // Panel is offset by 24px in styles so that the label has room to display.
+ expect((inputTop - 24).toFixed(2))
+ .toEqual(panelBottom.toFixed(2), `Expected panel to stay aligned after filtering.`);
+ expect(fixture.componentInstance.trigger.autocomplete.positionY)
+ .toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
});
});
@@ -553,5 +626,15 @@ class FakeKeyboardEvent {
preventDefault() {}
}
+class FakeViewportRuler {
+ getViewportRect() {
+ return {
+ left: 0, top: 0, width: 500, height: 500, bottom: 500, right: 500
+ };
+ }
+ getViewportScrollPosition() {
+ return {top: 0, left: 0};
+ }
+}
diff --git a/src/lib/autocomplete/autocomplete.ts b/src/lib/autocomplete/autocomplete.ts
index 85f5df0f870b..e6608457f36a 100644
--- a/src/lib/autocomplete/autocomplete.ts
+++ b/src/lib/autocomplete/autocomplete.ts
@@ -7,6 +7,7 @@ import {
ViewEncapsulation
} from '@angular/core';
import {MdOption} from '../core';
+import {MenuPositionY} from '../menu/menu-positions';
/**
* Autocomplete IDs need to be unique across components, so this counter exists outside of
@@ -24,10 +25,22 @@ let _uniqueAutocompleteIdCounter = 0;
})
export class MdAutocomplete {
+ /** Whether the autocomplete panel displays above or below its trigger. */
+ positionY: MenuPositionY = 'below';
+
@ViewChild(TemplateRef) template: TemplateRef;
@ContentChildren(MdOption) options: QueryList;
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`;
+
+ /** Sets a class on the panel based on its position (used to set y-offset). */
+ _getPositionClass() {
+ return {
+ 'md-autocomplete-panel-below': this.positionY === 'below',
+ 'md-autocomplete-panel-above': this.positionY === 'above'
+ };
+ }
+
}
diff --git a/src/lib/autocomplete/index.ts b/src/lib/autocomplete/index.ts
index 9ed105eb737a..6c62ddfc894a 100644
--- a/src/lib/autocomplete/index.ts
+++ b/src/lib/autocomplete/index.ts
@@ -1,4 +1,5 @@
import {ModuleWithProviders, NgModule} from '@angular/core';
+import {CommonModule} from '@angular/common';
import {
MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, DefaultStyleCompatibilityModeModule
} from '../core';
@@ -8,7 +9,7 @@ export * from './autocomplete';
export * from './autocomplete-trigger';
@NgModule({
- imports: [MdOptionModule, OverlayModule, DefaultStyleCompatibilityModeModule],
+ imports: [MdOptionModule, OverlayModule, DefaultStyleCompatibilityModeModule, CommonModule],
exports: [
MdAutocomplete, MdOptionModule, MdAutocompleteTrigger, DefaultStyleCompatibilityModeModule
],
diff --git a/src/lib/core/overlay/position/connected-position-strategy.spec.ts b/src/lib/core/overlay/position/connected-position-strategy.spec.ts
index c50217cd982e..a709ad5e1b89 100644
--- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts
+++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts
@@ -216,6 +216,39 @@ describe('ConnectedPositionStrategy', () => {
expect(overlayRect.right).toBe(originRect.left);
});
+ it('should recalculate and set the last position with recalculateLastPosition()', () => {
+ // Use the fake viewport ruler because we don't know *exactly* how big the viewport is.
+ fakeViewportRuler.fakeRect = {
+ top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500
+ };
+ positionBuilder = new OverlayPositionBuilder(fakeViewportRuler);
+
+ // Push the trigger down so the overlay doesn't have room to open on the bottom.
+ originElement.style.top = '475px';
+ originRect = originElement.getBoundingClientRect();
+
+ strategy = positionBuilder.connectedTo(
+ fakeElementRef,
+ {originX: 'start', originY: 'bottom'},
+ {overlayX: 'start', overlayY: 'top'})
+ .withFallbackPosition(
+ {originX: 'start', originY: 'top'},
+ {overlayX: 'start', overlayY: 'bottom'});
+
+ // This should apply the fallback position, as the original position won't fit.
+ strategy.apply(overlayElement);
+
+ // Now make the overlay small enough to fit in the first preferred position.
+ overlayElement.style.height = '15px';
+
+ // This should only re-align in the last position, even though the first would fit.
+ strategy.recalculateLastPosition();
+
+ let overlayRect = overlayElement.getBoundingClientRect();
+ expect(overlayRect.bottom).toBe(originRect.top,
+ 'Expected overlay to be re-aligned to the trigger in the previous position.');
+ });
+
it('should position a panel properly when rtl', () => {
// must make the overlay longer than the origin to properly test attachment
overlayElement.style.width = `500px`;
diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts
index c9b85e8e991c..8ec7b9e1b567 100644
--- a/src/lib/core/overlay/position/connected-position-strategy.ts
+++ b/src/lib/core/overlay/position/connected-position-strategy.ts
@@ -53,6 +53,12 @@ export class ConnectedPositionStrategy implements PositionStrategy {
/** The origin element against which the overlay will be positioned. */
private _origin: HTMLElement;
+ /** The overlay pane element. */
+ private _pane: HTMLElement;
+
+ /** The last position to have been calculated as the best fit position. */
+ private _lastConnectedPosition: ConnectionPositionPair;
+
_onPositionChange:
Subject = new Subject();
@@ -89,6 +95,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
* @returns Resolves when the styles have been applied.
*/
apply(element: HTMLElement): Promise {
+ // Cache the overlay pane element in case re-calculating position is necessary
+ this._pane = element;
+
// We need the bounding rects for the origin and the overlay to determine how to position
// the overlay relative to the origin.
const originRect = this._origin.getBoundingClientRect();
@@ -112,6 +121,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
if (overlayPoint.fitsInViewport) {
this._setElementPosition(element, overlayPoint);
+ // Save the last connected position in case the position needs to be re-calculated.
+ this._lastConnectedPosition = pos;
+
// Notify that the position has been changed along with its change properties.
const scrollableViewProperties = this.getScrollableViewProperties(element);
const positionChange = new ConnectedOverlayPositionChange(pos, scrollableViewProperties);
@@ -130,6 +142,22 @@ export class ConnectedPositionStrategy implements PositionStrategy {
return Promise.resolve(null);
}
+ /**
+ * This re-aligns the overlay element with the trigger in its last calculated position,
+ * even if a position higher in the "preferred positions" list would now fit. This
+ * allows one to re-align the panel without changing the orientation of the panel.
+ */
+ recalculateLastPosition(): void {
+ const originRect = this._origin.getBoundingClientRect();
+ const overlayRect = this._pane.getBoundingClientRect();
+ const viewportRect = this._viewportRuler.getViewportRect();
+
+ let originPoint = this._getOriginConnectionPoint(originRect, this._lastConnectedPosition);
+ let overlayPoint =
+ this._getOverlayPoint(originPoint, overlayRect, viewportRect, this._lastConnectedPosition);
+ this._setElementPosition(this._pane, overlayPoint);
+ }
+
/**
* Sets the list of Scrollable containers that host the origin element so that
* on reposition we can evaluate if it or the overlay has been clipped or outside view. Every