Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(autocomplete): add fallback positions #2726

Merged
merged 1 commit into from
Jan 25, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'
};
}

}

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
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 @@ -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`;
Expand Down
Loading