Skip to content

Commit 127d3a5

Browse files
committed
feat(autocomplete): add autocomplete panel toggling
1 parent dccbe41 commit 127d3a5

21 files changed

+460
-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 md-input 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: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
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+
private _closeWatcher: Subscription;
23+
24+
/* The autocomplete panel to be attached to this trigger. */
25+
@Input('mdAutocomplete') autocomplete: MdAutocomplete;
26+
27+
constructor(private _element: ElementRef, private _overlay: Overlay,
28+
private _vcr: ViewContainerRef) {}
29+
30+
ngOnDestroy() { this.destroyPanel(); }
31+
32+
/* Whether or not the autocomplete panel is open. */
33+
get panelOpen(): boolean {
34+
return this._panelOpen;
35+
}
36+
37+
openPanel(): void {
38+
if (!this._overlayRef) {
39+
this._createOverlay();
40+
}
41+
42+
if (!this._overlayRef.hasAttached()) {
43+
this._overlayRef.attach(this._portal);
44+
this._watchForClose();
45+
}
46+
47+
this._panelOpen = true;
48+
}
49+
50+
closePanel(): void {
51+
if (this._overlayRef && this._overlayRef.hasAttached()) {
52+
this._overlayRef.detach();
53+
}
54+
55+
this._panelOpen = false;
56+
}
57+
58+
destroyPanel(): void {
59+
if (this._overlayRef) {
60+
this.closePanel();
61+
this._overlayRef.dispose();
62+
this._overlayRef = null;
63+
}
64+
}
65+
66+
/**
67+
* This method will close the panel if it receives a selection event from any of the options
68+
* or a click on the backdrop.
69+
*/
70+
private _watchForClose() {
71+
// TODO(kara): add tab event watcher when adding keyboard events
72+
this._closeWatcher = Observable.merge(...this._getOptionObs(), this._overlayRef.backdropClick())
73+
.first()
74+
.subscribe(() => this.closePanel());
75+
}
76+
77+
/**
78+
* This method maps all the autocomplete's child options into a flattened list
79+
* of their selection events. This map will be used to merge the option events and
80+
* the backdrop click into one observable.
81+
*/
82+
private _getOptionObs(): Observable<any>[] {
83+
return this.autocomplete.options.map((option) => option.onSelect);
84+
}
85+
86+
private _createOverlay(): void {
87+
this._portal = new TemplatePortal(this.autocomplete.template, this._vcr);
88+
this._overlayRef = this._overlay.create(this._getOverlayConfig());
89+
}
90+
91+
private _getOverlayConfig(): OverlayState {
92+
const overlayState = new OverlayState();
93+
overlayState.positionStrategy = this._getOverlayPosition();
94+
overlayState.width = this._getHostWidth();
95+
overlayState.hasBackdrop = true;
96+
overlayState.backdropClass = 'md-overlay-transparent-backdrop';
97+
return overlayState;
98+
}
99+
100+
private _getOverlayPosition(): PositionStrategy {
101+
return this._overlay.position().connectedTo(
102+
this._element,
103+
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'})
104+
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET);
105+
}
106+
107+
/** Returns the width of the input element, so the panel width can match it. */
108+
private _getHostWidth(): number {
109+
return this._element.nativeElement.getBoundingClientRect().width;
110+
}
111+
112+
}
113+
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: 168 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,188 @@
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+
19+
// add fixed positioning to match real overlay container styles
20+
overlayContainerElement.style.position = 'fixed';
21+
overlayContainerElement.style.top = '0';
22+
overlayContainerElement.style.left = '0';
23+
document.body.appendChild(overlayContainerElement);
24+
25+
// remove body padding to keep consistent cross-browser
26+
document.body.style.padding = '0';
27+
document.body.style.margin = '0';
28+
29+
return {getContainerElement: () => overlayContainerElement};
30+
}},
31+
]
1232
});
1333

1434
TestBed.compileComponents();
1535
}));
1636

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

21142
});
22143

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

0 commit comments

Comments
 (0)