Skip to content

Commit

Permalink
feat(autocomplete): add fallback positions
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Jan 19, 2017
1 parent 6d1d578 commit 152b6b2
Show file tree
Hide file tree
Showing 8 changed files with 120 additions and 9 deletions.
2 changes: 2 additions & 0 deletions src/demo-app/autocomplete/autocomplete-demo.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Space above cards: <input type="number" [formControl]="topHeightCtrl">
<div [style.height.px]="topHeightCtrl.value"></div>
<div class="demo-autocomplete">
<md-card>
<div>Reactive value: {{ stateCtrl.value }}</div>
Expand Down
1 change: 1 addition & 0 deletions src/demo-app/autocomplete/autocomplete-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
35 changes: 28 additions & 7 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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;

Expand All @@ -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 {
Expand Down Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion src/lib/autocomplete/autocomplete.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<div class="md-autocomplete-panel" role="listbox" [id]="id">
<div class="md-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_getPositionClass()">
<ng-content></ng-content>
</div>
</template>
23 changes: 23 additions & 0 deletions src/lib/autocomplete/autocomplete.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
50 changes: 50 additions & 0 deletions src/lib/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -37,6 +38,7 @@ describe('MdAutocomplete', () => {
{provide: Dir, useFactory: () => {
return {value: dir};
}},
{provide: ViewportRuler, useClass: FakeViewportRuler}
]
});

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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};
}
}

13 changes: 13 additions & 0 deletions src/lib/autocomplete/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<any>;
@ContentChildren(MdOption) options: QueryList<MdOption>;

/** 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'
};
}

}

3 changes: 2 additions & 1 deletion src/lib/autocomplete/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {ModuleWithProviders, NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {
MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, DefaultStyleCompatibilityModeModule
} from '../core';
Expand All @@ -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
],
Expand Down

0 comments on commit 152b6b2

Please sign in to comment.