Skip to content

Commit

Permalink
feat(autocomplete): add fallback positions (#2726)
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Feb 3, 2017
1 parent bd7f240 commit 8fc7706
Show file tree
Hide file tree
Showing 10 changed files with 228 additions and 12 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
47 changes: 39 additions & 8 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,8 +35,12 @@ 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;
private _positionStrategy: ConnectedPositionStrategy;

/* The autocomplete panel to be attached to this trigger. */
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
Expand All @@ -51,7 +53,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 @@ -124,7 +132,7 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
// create a new stream of panelClosingActions, replacing any previous streams
// that were created, and flatten it so our stream only emits closing events...
.switchMap(() => {
this._resetActiveItem();
this._resetPanel();
return this.panelClosingActions;
})
// when the first closing event occurs...
Expand Down Expand Up @@ -174,10 +182,24 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
}

private _getOverlayPosition(): PositionStrategy {
return this._overlay.position().connectedTo(
this._positionStrategy = 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(this._positionStrategy);
return this._positionStrategy;
}

/**
* 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 All @@ -190,5 +212,14 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
this._keyManager.setActiveItem(-1);
}

/**
* Resets the active item and re-calculates alignment of the panel in case its size
* has changed due to fewer or greater number of options.
*/
private _resetPanel() {
this._resetActiveItem();
this._positionStrategy.recalculateLastPosition();
}

}

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;
}
}
87 changes: 85 additions & 2 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 All @@ -35,6 +36,7 @@ describe('MdAutocomplete', () => {
{provide: Dir, useFactory: () => {
return {value: dir};
}},
{provide: ViewportRuler, useClass: FakeViewportRuler}
]
});

Expand Down Expand Up @@ -392,8 +394,8 @@ describe('MdAutocomplete', () => {
});

describe('aria', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;

beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
Expand Down Expand Up @@ -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<SimpleAutocomplete>;
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.`);
});

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

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

}

4 changes: 3 additions & 1 deletion src/lib/autocomplete/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {ModuleWithProviders, NgModule} from '@angular/core';

import {MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, CompatibilityModule} from '../core';
import {CommonModule} from '@angular/common';
import {MdAutocomplete} from './autocomplete';
import {MdAutocompleteTrigger} from './autocomplete-trigger';
export * from './autocomplete';
export * from './autocomplete-trigger';

@NgModule({
imports: [MdOptionModule, OverlayModule, CompatibilityModule],
imports: [MdOptionModule, OverlayModule, CompatibilityModule, CommonModule],
exports: [MdAutocomplete, MdOptionModule, MdAutocompleteTrigger, CompatibilityModule],
declarations: [MdAutocomplete, MdAutocompleteTrigger],
})
Expand Down
33 changes: 33 additions & 0 deletions src/lib/core/overlay/position/connected-position-strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,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`;
Expand Down
Loading

0 comments on commit 8fc7706

Please sign in to comment.