From e3c7ed57da949c8a15ad9ea08e35fc1d5a744d17 Mon Sep 17 00:00:00 2001 From: Kara Erickson Date: Mon, 23 Jan 2017 16:29:14 -0800 Subject: [PATCH] feat(autocomplete): add fallback positions --- .../autocomplete/autocomplete-demo.html | 2 + .../autocomplete/autocomplete-demo.ts | 1 + src/lib/autocomplete/autocomplete-trigger.ts | 47 ++++++++-- src/lib/autocomplete/autocomplete.html | 2 +- src/lib/autocomplete/autocomplete.scss | 23 +++++ src/lib/autocomplete/autocomplete.spec.ts | 87 ++++++++++++++++++- src/lib/autocomplete/autocomplete.ts | 13 +++ src/lib/autocomplete/index.ts | 3 +- .../connected-position-strategy.spec.ts | 33 +++++++ .../position/connected-position-strategy.ts | 28 ++++++ 10 files changed, 227 insertions(+), 12 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..1b3fca3aa07c 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,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(); + } + } 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 a431cfce3092..37411c60aaf8 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; @@ -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; - let input: HTMLInputElement; + let fixture: ComponentFixture; + 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; + 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}; + } +} 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 ], diff --git a/src/lib/core/overlay/position/connected-position-strategy.spec.ts b/src/lib/core/overlay/position/connected-position-strategy.spec.ts index c50217cd982e..a709ad5e1b89 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.spec.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.spec.ts @@ -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`; diff --git a/src/lib/core/overlay/position/connected-position-strategy.ts b/src/lib/core/overlay/position/connected-position-strategy.ts index c9b85e8e991c..8ec7b9e1b567 100644 --- a/src/lib/core/overlay/position/connected-position-strategy.ts +++ b/src/lib/core/overlay/position/connected-position-strategy.ts @@ -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 = new Subject(); @@ -89,6 +95,9 @@ export class ConnectedPositionStrategy implements PositionStrategy { * @returns Resolves when the styles have been applied. */ apply(element: HTMLElement): Promise { + // 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