feat(autocomplete): add fallback positions (#2726)

This commit is contained in:
Kara
2017-01-24 16:16:16 -08:00
parent bd7f2400bd
commit 8fc77069f8
10 changed files with 228 additions and 12 deletions
@@ -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>
@@ -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[];
+39 -8
View File
@@ -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,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;
@@ -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 {
@@ -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...
@@ -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. */
@@ -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();
}
}
+1 -1
View File
@@ -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
View File
@@ -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;
}
}
+85 -2
View File
@@ -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<SimpleAutocomplete>;
let input: HTMLInputElement;
let fixture: ComponentFixture<SimpleAutocomplete>;
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<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.`);
});
});
@@ -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
View File
@@ -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<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 -1
View File
@@ -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],
})
@@ -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`;
@@ -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<ConnectedOverlayPositionChange> = new Subject<ConnectedOverlayPositionChange>();
@@ -89,6 +95,9 @@ export class ConnectedPositionStrategy implements PositionStrategy {
* @returns Resolves when the styles have been applied.
*/
apply(element: HTMLElement): Promise<void> {
// 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