Skip to content

Commit d4ab3d3

Browse files
authored
feat(autocomplete): add autocomplete panel toggling (#2452)
1 parent 55e5686 commit d4ab3d3

21 files changed

+461
-76
lines changed
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
11
<div class="demo-autocomplete">
2-
<md-autocomplete></md-autocomplete>
2+
<md-input-container>
3+
<input mdInput placeholder="State" [mdAutocomplete]="auto">
4+
</md-input-container>
5+
6+
<md-autocomplete #auto="mdAutocomplete">
7+
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
8+
</md-autocomplete>
39
</div>

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,33 @@ import {Component} from '@angular/core';
66
templateUrl: 'autocomplete-demo.html',
77
styleUrls: ['autocomplete-demo.css'],
88
})
9-
export class AutocompleteDemo {}
9+
export class AutocompleteDemo {
10+
states = [
11+
{code: 'AL', name: 'Alabama'},
12+
{code: 'AZ', name: 'Arizona'},
13+
{code: 'CA', name: 'California'},
14+
{code: 'CO', name: 'Colorado'},
15+
{code: 'CT', name: 'Connecticut'},
16+
{code: 'FL', name: 'Florida'},
17+
{code: 'GA', name: 'Georgia'},
18+
{code: 'ID', name: 'Idaho'},
19+
{code: 'KS', name: 'Kansas'},
20+
{code: 'LA', name: 'Louisiana'},
21+
{code: 'MA', name: 'Massachusetts'},
22+
{code: 'MN', name: 'Minnesota'},
23+
{code: 'MI', name: 'Mississippi'},
24+
{code: 'NY', name: 'New York'},
25+
{code: 'NC', name: 'North Carolina'},
26+
{code: 'OK', name: 'Oklahoma'},
27+
{code: 'OH', name: 'Ohio'},
28+
{code: 'OR', name: 'Oregon'},
29+
{code: 'PA', name: 'Pennsylvania'},
30+
{code: 'SC', name: 'South Carolina'},
31+
{code: 'TN', name: 'Tennessee'},
32+
{code: 'TX', name: 'Texas'},
33+
{code: 'VA', name: 'Virginia'},
34+
{code: 'WA', name: 'Washington'},
35+
{code: 'WI', name: 'Wisconsin'},
36+
{code: 'WY', name: 'Wyoming'},
37+
];
38+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
@import '../core/theming/theming';
22

33
@mixin md-autocomplete-theme($theme) {
4+
$foreground: map-get($theme, foreground);
5+
$background: map-get($theme, background);
46

7+
md-option {
8+
background: md-color($background, card);
9+
color: md-color($foreground, text);
10+
11+
&.md-selected {
12+
background: md-color($background, card);
13+
color: md-color($foreground, text);
14+
}
15+
}
516
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import {Directive, ElementRef, Input, ViewContainerRef, OnDestroy} from '@angular/core';
2+
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core';
3+
import {MdAutocomplete} from './autocomplete';
4+
import {PositionStrategy} from '../core/overlay/position/position-strategy';
5+
import {Observable} from 'rxjs/Observable';
6+
import {Subscription} from 'rxjs/Subscription';
7+
import 'rxjs/add/observable/merge';
8+
9+
/** The panel needs a slight y-offset to ensure the input underline displays. */
10+
export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
11+
12+
@Directive({
13+
selector: 'input[mdAutocomplete], input[matAutocomplete]',
14+
host: {
15+
'(focus)': 'openPanel()'
16+
}
17+
})
18+
export class MdAutocompleteTrigger implements OnDestroy {
19+
private _overlayRef: OverlayRef;
20+
private _portal: TemplatePortal;
21+
private _panelOpen: boolean = false;
22+
23+
/** The subscription to events that close the autocomplete panel. */
24+
private _closingActionsSubscription: Subscription;
25+
26+
/* The autocomplete panel to be attached to this trigger. */
27+
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
28+
29+
constructor(private _element: ElementRef, private _overlay: Overlay,
30+
private _viewContainerRef: ViewContainerRef) {}
31+
32+
ngOnDestroy() { this._destroyPanel(); }
33+
34+
/* Whether or not the autocomplete panel is open. */
35+
get panelOpen(): boolean {
36+
return this._panelOpen;
37+
}
38+
39+
/** Opens the autocomplete suggestion panel. */
40+
openPanel(): void {
41+
if (!this._overlayRef) {
42+
this._createOverlay();
43+
}
44+
45+
if (!this._overlayRef.hasAttached()) {
46+
this._overlayRef.attach(this._portal);
47+
this._closingActionsSubscription =
48+
this.panelClosingActions.subscribe(() => this.closePanel());
49+
}
50+
51+
this._panelOpen = true;
52+
}
53+
54+
/** Closes the autocomplete suggestion panel. */
55+
closePanel(): void {
56+
if (this._overlayRef && this._overlayRef.hasAttached()) {
57+
this._overlayRef.detach();
58+
}
59+
60+
this._closingActionsSubscription.unsubscribe();
61+
this._panelOpen = false;
62+
}
63+
64+
/**
65+
* A stream of actions that should close the autocomplete panel, including
66+
* when an option is selected and when the backdrop is clicked.
67+
*/
68+
get panelClosingActions(): Observable<any> {
69+
// TODO(kara): add tab event observable with keyboard event PR
70+
return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick());
71+
}
72+
73+
/** Stream of autocomplete option selections. */
74+
get optionSelections(): Observable<any>[] {
75+
return this.autocomplete.options.map(option => option.onSelect);
76+
}
77+
78+
/** Destroys the autocomplete suggestion panel. */
79+
private _destroyPanel(): void {
80+
if (this._overlayRef) {
81+
this.closePanel();
82+
this._overlayRef.dispose();
83+
this._overlayRef = null;
84+
}
85+
}
86+
87+
private _createOverlay(): void {
88+
this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef);
89+
this._overlayRef = this._overlay.create(this._getOverlayConfig());
90+
}
91+
92+
private _getOverlayConfig(): OverlayState {
93+
const overlayState = new OverlayState();
94+
overlayState.positionStrategy = this._getOverlayPosition();
95+
overlayState.width = this._getHostWidth();
96+
overlayState.hasBackdrop = true;
97+
overlayState.backdropClass = 'md-overlay-transparent-backdrop';
98+
return overlayState;
99+
}
100+
101+
private _getOverlayPosition(): PositionStrategy {
102+
return this._overlay.position().connectedTo(
103+
this._element,
104+
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'})
105+
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET);
106+
}
107+
108+
/** Returns the width of the input element, so the panel width can match it. */
109+
private _getHostWidth(): number {
110+
return this._element.nativeElement.getBoundingClientRect().width;
111+
}
112+
113+
}
114+
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
I'm an autocomplete!
1+
<template>
2+
<div class="md-autocomplete-panel">
3+
<ng-content></ng-content>
4+
</div>
5+
</template>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
@import '../core/style/menu-common';
2+
3+
.md-autocomplete-panel {
4+
@include md-menu-base();
5+
}
Lines changed: 164 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,184 @@
1-
import {TestBed, async} from '@angular/core/testing';
2-
import {Component} from '@angular/core';
3-
import {MdAutocompleteModule} from './index';
1+
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
2+
import {Component, ViewChild} from '@angular/core';
3+
import {By} from '@angular/platform-browser';
4+
import {MdAutocompleteModule, MdAutocompleteTrigger} from './index';
5+
import {OverlayContainer} from '../core/overlay/overlay-container';
6+
import {MdInputModule} from '../input/index';
47

58
describe('MdAutocomplete', () => {
9+
let overlayContainerElement: HTMLElement;
610

711
beforeEach(async(() => {
812
TestBed.configureTestingModule({
9-
imports: [MdAutocompleteModule.forRoot()],
13+
imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()],
1014
declarations: [SimpleAutocomplete],
11-
providers: []
15+
providers: [
16+
{provide: OverlayContainer, useFactory: () => {
17+
overlayContainerElement = document.createElement('div');
18+
document.body.appendChild(overlayContainerElement);
19+
20+
// remove body padding to keep consistent cross-browser
21+
document.body.style.padding = '0';
22+
document.body.style.margin = '0';
23+
24+
return {getContainerElement: () => overlayContainerElement};
25+
}},
26+
]
1227
});
1328

1429
TestBed.compileComponents();
1530
}));
1631

17-
it('should have a test', () => {
18-
expect(true).toBe(true);
32+
describe('panel toggling', () => {
33+
let fixture: ComponentFixture<SimpleAutocomplete>;
34+
let trigger: HTMLElement;
35+
36+
beforeEach(() => {
37+
fixture = TestBed.createComponent(SimpleAutocomplete);
38+
fixture.detectChanges();
39+
40+
trigger = fixture.debugElement.query(By.css('input')).nativeElement;
41+
});
42+
43+
it('should open the panel when the input is focused', () => {
44+
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
45+
dispatchEvent('focus', trigger);
46+
fixture.detectChanges();
47+
48+
expect(fixture.componentInstance.trigger.panelOpen)
49+
.toBe(true, `Expected panel state to read open when input is focused.`);
50+
expect(overlayContainerElement.textContent)
51+
.toContain('Alabama', `Expected panel to display when input is focused.`);
52+
expect(overlayContainerElement.textContent)
53+
.toContain('California', `Expected panel to display when input is focused.`);
54+
});
55+
56+
it('should open the panel programmatically', () => {
57+
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
58+
fixture.componentInstance.trigger.openPanel();
59+
fixture.detectChanges();
60+
61+
expect(fixture.componentInstance.trigger.panelOpen)
62+
.toBe(true, `Expected panel state to read open when opened programmatically.`);
63+
expect(overlayContainerElement.textContent)
64+
.toContain('Alabama', `Expected panel to display when opened programmatically.`);
65+
expect(overlayContainerElement.textContent)
66+
.toContain('California', `Expected panel to display when opened programmatically.`);
67+
});
68+
69+
it('should close the panel when a click occurs outside it', async(() => {
70+
dispatchEvent('focus', trigger);
71+
fixture.detectChanges();
72+
73+
const backdrop =
74+
overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement;
75+
backdrop.click();
76+
fixture.detectChanges();
77+
78+
fixture.whenStable().then(() => {
79+
expect(fixture.componentInstance.trigger.panelOpen)
80+
.toBe(false, `Expected clicking outside the panel to set its state to closed.`);
81+
expect(overlayContainerElement.textContent)
82+
.toEqual('', `Expected clicking outside the panel to close the panel.`);
83+
});
84+
}));
85+
86+
it('should close the panel when an option is clicked', async(() => {
87+
dispatchEvent('focus', trigger);
88+
fixture.detectChanges();
89+
90+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
91+
option.click();
92+
fixture.detectChanges();
93+
94+
fixture.whenStable().then(() => {
95+
expect(fixture.componentInstance.trigger.panelOpen)
96+
.toBe(false, `Expected clicking an option to set the panel state to closed.`);
97+
expect(overlayContainerElement.textContent)
98+
.toEqual('', `Expected clicking an option to close the panel.`);
99+
});
100+
}));
101+
102+
it('should close the panel when a newly created option is clicked', async(() => {
103+
fixture.componentInstance.states.unshift({code: 'TEST', name: 'test'});
104+
fixture.detectChanges();
105+
106+
dispatchEvent('focus', trigger);
107+
fixture.detectChanges();
108+
109+
const option = overlayContainerElement.querySelector('md-option') as HTMLElement;
110+
option.click();
111+
fixture.detectChanges();
112+
113+
fixture.whenStable().then(() => {
114+
expect(fixture.componentInstance.trigger.panelOpen)
115+
.toBe(false, `Expected clicking a new option to set the panel state to closed.`);
116+
expect(overlayContainerElement.textContent)
117+
.toEqual('', `Expected clicking a new option to close the panel.`);
118+
});
119+
}));
120+
121+
it('should close the panel programmatically', async(() => {
122+
fixture.componentInstance.trigger.openPanel();
123+
fixture.detectChanges();
124+
125+
fixture.componentInstance.trigger.closePanel();
126+
fixture.detectChanges();
127+
128+
fixture.whenStable().then(() => {
129+
expect(fixture.componentInstance.trigger.panelOpen)
130+
.toBe(false, `Expected closing programmatically to set the panel state to closed.`);
131+
expect(overlayContainerElement.textContent)
132+
.toEqual('', `Expected closing programmatically to close the panel.`);
133+
});
134+
}));
135+
19136
});
20137

21138
});
22139

23140
@Component({
24141
template: `
25-
<md-autocomplete></md-autocomplete>
142+
<md-input-container>
143+
<input mdInput placeholder="State" [mdAutocomplete]="auto">
144+
</md-input-container>
145+
146+
<md-autocomplete #auto="mdAutocomplete">
147+
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option>
148+
</md-autocomplete>
26149
`
27150
})
28-
class SimpleAutocomplete {}
151+
class SimpleAutocomplete {
152+
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
153+
154+
states = [
155+
{code: 'AL', name: 'Alabama'},
156+
{code: 'CA', name: 'California'},
157+
{code: 'FL', name: 'Florida'},
158+
{code: 'KS', name: 'Kansas'},
159+
{code: 'MA', name: 'Massachusetts'},
160+
{code: 'NY', name: 'New York'},
161+
{code: 'OR', name: 'Oregon'},
162+
{code: 'PA', name: 'Pennsylvania'},
163+
{code: 'TN', name: 'Tennessee'},
164+
{code: 'VA', name: 'Virginia'},
165+
{code: 'WY', name: 'Wyoming'},
166+
];
167+
}
168+
169+
170+
/**
171+
* TODO: Move this to core testing utility until Angular has event faking
172+
* support.
173+
*
174+
* Dispatches an event from an element.
175+
* @param eventName Name of the event
176+
* @param element The element from which the event will be dispatched.
177+
*/
178+
function dispatchEvent(eventName: string, element: HTMLElement): void {
179+
let event = document.createEvent('Event');
180+
event.initEvent(eventName, true, true);
181+
element.dispatchEvent(event);
182+
}
183+
29184

0 commit comments

Comments
 (0)