From 152b6b2d7815acab413032d4e792d9b8bae7c404 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Tue, 17 Jan 2017 17:53:52 -0800 Subject: [PATCH] feat(autocomplete): add fallback positions --- .../autocomplete/autocomplete-demo.html | 2 + .../autocomplete/autocomplete-demo.ts | 1 + src/lib/autocomplete/autocomplete-trigger.ts | 35 ++++++++++--- src/lib/autocomplete/autocomplete.html | 2 +- src/lib/autocomplete/autocomplete.scss | 23 +++++++++ src/lib/autocomplete/autocomplete.spec.ts | 50 +++++++++++++++++++ src/lib/autocomplete/autocomplete.ts | 13 +++++ src/lib/autocomplete/index.ts | 3 +- 8 files changed, 120 insertions(+), 9 deletions(-) diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html index 95e6a345b179..d18b8c79f11c 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.html +++ b/src/demo-app/autocomplete/autocomplete-demo.html @@ -1,3 +1,5 @@ +Space above cards: +
Reactive value: {{ stateCtrl.value }}
diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts index 50ae3077dc4d..06cdff9510a2 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.ts +++ b/src/demo-app/autocomplete/autocomplete-demo.ts @@ -12,6 +12,7 @@ import {Subscription} from 'rxjs/Subscription'; export class AutocompleteDemo implements OnDestroy { stateCtrl = new FormControl(); currentState = ''; + topHeightCtrl = new FormControl(0); reactiveStates: any[]; tdStates: any[]; diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index 586e882ee5fb..ece534d96f03 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -5,19 +5,17 @@ import {NgControl} from '@angular/forms'; import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core'; import {MdAutocomplete} from './autocomplete'; import {PositionStrategy} from '../core/overlay/position/position-strategy'; +import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy'; import {Observable} from 'rxjs/Observable'; import {MdOptionSelectEvent, MdOption} from '../core/option/option'; import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager'; import {ENTER} from '../core/keyboard/keycodes'; +import {Subscription} from 'rxjs/Subscription'; import 'rxjs/add/observable/merge'; import {Dir} from '../core/rtl/dir'; import 'rxjs/add/operator/startWith'; import 'rxjs/add/operator/switchMap'; - -/** The panel needs a slight y-offset to ensure the input underline displays. */ -export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6; - @Directive({ selector: 'input[mdAutocomplete], input[matAutocomplete]', host: { @@ -37,6 +35,9 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy { private _portal: TemplatePortal; private _panelOpen: boolean = false; + /** The subscription to positioning changes in the autocomplete panel. */ + private _panelPositionSub: Subscription; + /** Manages active item in option list based on key events. */ private _keyManager: ActiveDescendantKeyManager; @@ -51,7 +52,13 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy { this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options); } - ngOnDestroy() { this._destroyPanel(); } + ngOnDestroy() { + if (this._panelPositionSub) { + this._panelPositionSub.unsubscribe(); + } + + this._destroyPanel(); + } /* Whether or not the autocomplete panel is open. */ get panelOpen(): boolean { @@ -174,10 +181,24 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy { } private _getOverlayPosition(): PositionStrategy { - return this._overlay.position().connectedTo( + const strategy = this._overlay.position().connectedTo( this._element, {originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) - .withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET); + .withFallbackPosition( + {originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'bottom'} + ); + this._subscribeToPositionChanges(strategy); + return strategy; + } + + /** + * This method subscribes to position changes in the autocomplete panel, so the panel's + * y-offset can be adjusted to match the new position. + */ + private _subscribeToPositionChanges(strategy: ConnectedPositionStrategy) { + this._panelPositionSub = strategy.onPositionChange.subscribe(change => { + this.autocomplete.positionY = change.connectionPair.originY === 'top' ? 'above' : 'below'; + }); } /** Returns the width of the input element, so the panel width can match it. */ diff --git a/src/lib/autocomplete/autocomplete.html b/src/lib/autocomplete/autocomplete.html index 84b73b818e56..c1d9523c69de 100644 --- a/src/lib/autocomplete/autocomplete.html +++ b/src/lib/autocomplete/autocomplete.html @@ -1,5 +1,5 @@ \ 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 659169adfdc2..3ec7750fd1ad 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; @@ -37,6 +38,7 @@ describe('MdAutocomplete', () => { {provide: Dir, useFactory: () => { return {value: dir}; }}, + {provide: ViewportRuler, useClass: FakeViewportRuler} ] }); @@ -451,6 +453,44 @@ describe('MdAutocomplete', () => { }); + describe('Fallback positions', () => { + + 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.`); + }); + + }); + }); @Component({ @@ -523,5 +563,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 ],