Skip to content

Commit 0fe8959

Browse files
committed
feat(autocomplete): add fallback positions (#2726)
1 parent e7d528b commit 0fe8959

File tree

10 files changed

+228
-12
lines changed

10 files changed

+228
-12
lines changed

src/demo-app/autocomplete/autocomplete-demo.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
Space above cards: <input type="number" [formControl]="topHeightCtrl">
2+
<div [style.height.px]="topHeightCtrl.value"></div>
13
<div class="demo-autocomplete">
24
<md-card>
35
<div>Reactive value: {{ stateCtrl.value }}</div>

src/demo-app/autocomplete/autocomplete-demo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {Subscription} from 'rxjs/Subscription';
1212
export class AutocompleteDemo implements OnDestroy {
1313
stateCtrl = new FormControl();
1414
currentState = '';
15+
topHeightCtrl = new FormControl(0);
1516

1617
reactiveStates: any[];
1718
tdStates: any[];

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,17 @@ import {NgControl} from '@angular/forms';
55
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
66
import {MdAutocomplete} from './autocomplete';
77
import {PositionStrategy} from '../core/overlay/position/position-strategy';
8+
import {ConnectedPositionStrategy} from '../core/overlay/position/connected-position-strategy';
89
import {Observable} from 'rxjs/Observable';
910
import {MdOptionSelectEvent, MdOption} from '../core/option/option';
1011
import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager';
1112
import {ENTER} from '../core/keyboard/keycodes';
13+
import {Subscription} from 'rxjs/Subscription';
1214
import 'rxjs/add/observable/merge';
1315
import {Dir} from '../core/rtl/dir';
1416
import 'rxjs/add/operator/startWith';
1517
import 'rxjs/add/operator/switchMap';
1618

17-
18-
/** The panel needs a slight y-offset to ensure the input underline displays. */
19-
export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
20-
2119
@Directive({
2220
selector: 'input[mdAutocomplete], input[matAutocomplete]',
2321
host: {
@@ -37,8 +35,12 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
3735
private _portal: TemplatePortal;
3836
private _panelOpen: boolean = false;
3937

38+
/** The subscription to positioning changes in the autocomplete panel. */
39+
private _panelPositionSub: Subscription;
40+
4041
/** Manages active item in option list based on key events. */
4142
private _keyManager: ActiveDescendantKeyManager;
43+
private _positionStrategy: ConnectedPositionStrategy;
4244

4345
/* The autocomplete panel to be attached to this trigger. */
4446
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
@@ -51,7 +53,13 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
5153
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options);
5254
}
5355

54-
ngOnDestroy() { this._destroyPanel(); }
56+
ngOnDestroy() {
57+
if (this._panelPositionSub) {
58+
this._panelPositionSub.unsubscribe();
59+
}
60+
61+
this._destroyPanel();
62+
}
5563

5664
/* Whether or not the autocomplete panel is open. */
5765
get panelOpen(): boolean {
@@ -124,7 +132,7 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
124132
// create a new stream of panelClosingActions, replacing any previous streams
125133
// that were created, and flatten it so our stream only emits closing events...
126134
.switchMap(() => {
127-
this._resetActiveItem();
135+
this._resetPanel();
128136
return this.panelClosingActions;
129137
})
130138
// when the first closing event occurs...
@@ -174,10 +182,24 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
174182
}
175183

176184
private _getOverlayPosition(): PositionStrategy {
177-
return this._overlay.position().connectedTo(
185+
this._positionStrategy = this._overlay.position().connectedTo(
178186
this._element,
179187
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'})
180-
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET);
188+
.withFallbackPosition(
189+
{originX: 'start', originY: 'top'}, {overlayX: 'start', overlayY: 'bottom'}
190+
);
191+
this._subscribeToPositionChanges(this._positionStrategy);
192+
return this._positionStrategy;
193+
}
194+
195+
/**
196+
* This method subscribes to position changes in the autocomplete panel, so the panel's
197+
* y-offset can be adjusted to match the new position.
198+
*/
199+
private _subscribeToPositionChanges(strategy: ConnectedPositionStrategy) {
200+
this._panelPositionSub = strategy.onPositionChange.subscribe(change => {
201+
this.autocomplete.positionY = change.connectionPair.originY === 'top' ? 'above' : 'below';
202+
});
181203
}
182204

183205
/** Returns the width of the input element, so the panel width can match it. */
@@ -190,5 +212,14 @@ export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
190212
this._keyManager.setActiveItem(-1);
191213
}
192214

215+
/**
216+
* Resets the active item and re-calculates alignment of the panel in case its size
217+
* has changed due to fewer or greater number of options.
218+
*/
219+
private _resetPanel() {
220+
this._resetActiveItem();
221+
this._positionStrategy.recalculateLastPosition();
222+
}
223+
193224
}
194225

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<template>
2-
<div class="md-autocomplete-panel" role="listbox" [id]="id">
2+
<div class="md-autocomplete-panel" role="listbox" [id]="id" [ngClass]="_getPositionClass()">
33
<ng-content></ng-content>
44
</div>
55
</template>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
@import '../core/style/menu-common';
22

3+
/**
4+
* The max-height of the panel, currently matching md-select value.
5+
* TODO: Check value with MD team.
6+
*/
7+
$md-autocomplete-panel-max-height: 256px !default;
8+
9+
/** When in "below" position, the panel needs a slight y-offset to ensure the input underline displays. */
10+
$md-autocomplete-panel-below-offset: 6px !default;
11+
12+
/** When in "above" position, the panel needs a larger y-offset to ensure the label has room to display. */
13+
$md-autocomplete-panel-above-offset: -24px !default;
14+
315
.md-autocomplete-panel {
416
@include md-menu-base();
17+
18+
max-height: $md-autocomplete-panel-max-height;
19+
position: relative;
20+
21+
&.md-autocomplete-panel-below {
22+
top: $md-autocomplete-panel-below-offset;
23+
}
24+
25+
&.md-autocomplete-panel-above {
26+
top: $md-autocomplete-panel-above-offset;
27+
}
528
}

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms';
99
import {Subscription} from 'rxjs/Subscription';
1010
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
1111
import {MdOption} from '../core/option/option';
12+
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
1213

1314
describe('MdAutocomplete', () => {
1415
let overlayContainerElement: HTMLElement;
@@ -35,6 +36,7 @@ describe('MdAutocomplete', () => {
3536
{provide: Dir, useFactory: () => {
3637
return {value: dir};
3738
}},
39+
{provide: ViewportRuler, useClass: FakeViewportRuler}
3840
]
3941
});
4042

@@ -392,8 +394,8 @@ describe('MdAutocomplete', () => {
392394
});
393395

394396
describe('aria', () => {
395-
let fixture: ComponentFixture<SimpleAutocomplete>;
396-
let input: HTMLInputElement;
397+
let fixture: ComponentFixture<SimpleAutocomplete>;
398+
let input: HTMLInputElement;
397399

398400
beforeEach(() => {
399401
fixture = TestBed.createComponent(SimpleAutocomplete);
@@ -477,6 +479,77 @@ describe('MdAutocomplete', () => {
477479

478480
expect(input.getAttribute('aria-owns'))
479481
.toEqual(panel.getAttribute('id'), 'Expected aria-owns to match attached autocomplete.');
482+
483+
});
484+
485+
});
486+
487+
describe('Fallback positions', () => {
488+
let fixture: ComponentFixture<SimpleAutocomplete>;
489+
let input: HTMLInputElement;
490+
491+
beforeEach(() => {
492+
fixture = TestBed.createComponent(SimpleAutocomplete);
493+
fixture.detectChanges();
494+
495+
input = fixture.debugElement.query(By.css('input')).nativeElement;
496+
});
497+
498+
it('should use below positioning by default', () => {
499+
fixture.componentInstance.trigger.openPanel();
500+
fixture.detectChanges();
501+
502+
const inputBottom = input.getBoundingClientRect().bottom;
503+
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
504+
const panelTop = panel.getBoundingClientRect().top;
505+
506+
// Panel is offset by 6px in styles so that the underline has room to display.
507+
expect((inputBottom + 6).toFixed(2))
508+
.toEqual(panelTop.toFixed(2), `Expected panel top to match input bottom by default.`);
509+
expect(fixture.componentInstance.trigger.autocomplete.positionY)
510+
.toEqual('below', `Expected autocomplete positionY to default to below.`);
511+
});
512+
513+
it('should fall back to above position if panel cannot fit below', () => {
514+
// Push the autocomplete trigger down so it won't have room to open "below"
515+
input.style.top = '400px';
516+
input.style.position = 'relative';
517+
518+
fixture.componentInstance.trigger.openPanel();
519+
fixture.detectChanges();
520+
521+
const inputTop = input.getBoundingClientRect().top;
522+
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
523+
const panelBottom = panel.getBoundingClientRect().bottom;
524+
525+
// Panel is offset by 24px in styles so that the label has room to display.
526+
expect((inputTop - 24).toFixed(2))
527+
.toEqual(panelBottom.toFixed(2), `Expected panel to fall back to above position.`);
528+
expect(fixture.componentInstance.trigger.autocomplete.positionY)
529+
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
530+
});
531+
532+
it('should align panel properly when filtering in "above" position', () => {
533+
// Push the autocomplete trigger down so it won't have room to open "below"
534+
input.style.top = '400px';
535+
input.style.position = 'relative';
536+
537+
fixture.componentInstance.trigger.openPanel();
538+
fixture.detectChanges();
539+
540+
input.value = 'f';
541+
dispatchEvent('input', input);
542+
fixture.detectChanges();
543+
544+
const inputTop = input.getBoundingClientRect().top;
545+
const panel = overlayContainerElement.querySelector('.md-autocomplete-panel');
546+
const panelBottom = panel.getBoundingClientRect().bottom;
547+
548+
// Panel is offset by 24px in styles so that the label has room to display.
549+
expect((inputTop - 24).toFixed(2))
550+
.toEqual(panelBottom.toFixed(2), `Expected panel to stay aligned after filtering.`);
551+
expect(fixture.componentInstance.trigger.autocomplete.positionY)
552+
.toEqual('above', `Expected autocomplete positionY to be "above" if panel won't fit.`);
480553
});
481554

482555
});
@@ -553,5 +626,15 @@ class FakeKeyboardEvent {
553626
preventDefault() {}
554627
}
555628

629+
class FakeViewportRuler {
630+
getViewportRect() {
631+
return {
632+
left: 0, top: 0, width: 500, height: 500, bottom: 500, right: 500
633+
};
634+
}
556635

636+
getViewportScrollPosition() {
637+
return {top: 0, left: 0};
638+
}
639+
}
557640

src/lib/autocomplete/autocomplete.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
ViewEncapsulation
88
} from '@angular/core';
99
import {MdOption} from '../core';
10+
import {MenuPositionY} from '../menu/menu-positions';
1011

1112
/**
1213
* Autocomplete IDs need to be unique across components, so this counter exists outside of
@@ -24,10 +25,22 @@ let _uniqueAutocompleteIdCounter = 0;
2425
})
2526
export class MdAutocomplete {
2627

28+
/** Whether the autocomplete panel displays above or below its trigger. */
29+
positionY: MenuPositionY = 'below';
30+
2731
@ViewChild(TemplateRef) template: TemplateRef<any>;
2832
@ContentChildren(MdOption) options: QueryList<MdOption>;
2933

3034
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
3135
id: string = `md-autocomplete-${_uniqueAutocompleteIdCounter++}`;
36+
37+
/** Sets a class on the panel based on its position (used to set y-offset). */
38+
_getPositionClass() {
39+
return {
40+
'md-autocomplete-panel-below': this.positionY === 'below',
41+
'md-autocomplete-panel-above': this.positionY === 'above'
42+
};
43+
}
44+
3245
}
3346

src/lib/autocomplete/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import {ModuleWithProviders, NgModule} from '@angular/core';
2+
23
import {MdOptionModule, OverlayModule, OVERLAY_PROVIDERS, CompatibilityModule} from '../core';
4+
import {CommonModule} from '@angular/common';
35
import {MdAutocomplete} from './autocomplete';
46
import {MdAutocompleteTrigger} from './autocomplete-trigger';
57
export * from './autocomplete';
68
export * from './autocomplete-trigger';
79

810
@NgModule({
9-
imports: [MdOptionModule, OverlayModule, CompatibilityModule],
11+
imports: [MdOptionModule, OverlayModule, CompatibilityModule, CommonModule],
1012
exports: [MdAutocomplete, MdOptionModule, MdAutocompleteTrigger, CompatibilityModule],
1113
declarations: [MdAutocomplete, MdAutocompleteTrigger],
1214
})

src/lib/core/overlay/position/connected-position-strategy.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,39 @@ describe('ConnectedPositionStrategy', () => {
216216
expect(overlayRect.right).toBe(originRect.left);
217217
});
218218

219+
it('should recalculate and set the last position with recalculateLastPosition()', () => {
220+
// Use the fake viewport ruler because we don't know *exactly* how big the viewport is.
221+
fakeViewportRuler.fakeRect = {
222+
top: 0, left: 0, width: 500, height: 500, right: 500, bottom: 500
223+
};
224+
positionBuilder = new OverlayPositionBuilder(fakeViewportRuler);
225+
226+
// Push the trigger down so the overlay doesn't have room to open on the bottom.
227+
originElement.style.top = '475px';
228+
originRect = originElement.getBoundingClientRect();
229+
230+
strategy = positionBuilder.connectedTo(
231+
fakeElementRef,
232+
{originX: 'start', originY: 'bottom'},
233+
{overlayX: 'start', overlayY: 'top'})
234+
.withFallbackPosition(
235+
{originX: 'start', originY: 'top'},
236+
{overlayX: 'start', overlayY: 'bottom'});
237+
238+
// This should apply the fallback position, as the original position won't fit.
239+
strategy.apply(overlayElement);
240+
241+
// Now make the overlay small enough to fit in the first preferred position.
242+
overlayElement.style.height = '15px';
243+
244+
// This should only re-align in the last position, even though the first would fit.
245+
strategy.recalculateLastPosition();
246+
247+
let overlayRect = overlayElement.getBoundingClientRect();
248+
expect(overlayRect.bottom).toBe(originRect.top,
249+
'Expected overlay to be re-aligned to the trigger in the previous position.');
250+
});
251+
219252
it('should position a panel properly when rtl', () => {
220253
// must make the overlay longer than the origin to properly test attachment
221254
overlayElement.style.width = `500px`;

0 commit comments

Comments
 (0)