-
Notifications
You must be signed in to change notification settings - Fork 6.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(autocomplete): add autocomplete panel toggling
- Loading branch information
Showing
21 changed files
with
463 additions
and
76 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,9 @@ | ||
<div class="demo-autocomplete"> | ||
<md-autocomplete></md-autocomplete> | ||
<md-input-container> | ||
<input mdInput placeholder="State" [mdAutocomplete]="auto"> | ||
</md-input-container> | ||
|
||
<md-autocomplete #auto="mdAutocomplete"> | ||
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option> | ||
</md-autocomplete> | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,16 @@ | ||
@import '../core/theming/theming'; | ||
|
||
@mixin md-autocomplete-theme($theme) { | ||
$foreground: map-get($theme, foreground); | ||
$background: map-get($theme, background); | ||
|
||
md-option { | ||
background: md-color($background, card); | ||
color: md-color($foreground, text); | ||
|
||
&.md-selected { | ||
background: md-color($background, card); | ||
color: md-color($foreground, text); | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import {Directive, ElementRef, Input, ViewContainerRef, OnDestroy} from '@angular/core'; | ||
import {Overlay, OverlayRef, OverlayState, TemplatePortal} from '../core'; | ||
import {MdAutocomplete} from './autocomplete'; | ||
import {PositionStrategy} from '../core/overlay/position/position-strategy'; | ||
import {Observable} from 'rxjs/Observable'; | ||
import {Subscription} from 'rxjs/Subscription'; | ||
import 'rxjs/add/observable/merge'; | ||
|
||
/** 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: { | ||
'(focus)': 'openPanel()' | ||
} | ||
}) | ||
export class MdAutocompleteTrigger implements OnDestroy { | ||
private _overlayRef: OverlayRef; | ||
private _portal: TemplatePortal; | ||
private _panelOpen: boolean = false; | ||
private _closeWatcher: Subscription; | ||
|
||
/* The autocomplete panel to be attached to this trigger. */ | ||
@Input('mdAutocomplete') autocomplete: MdAutocomplete; | ||
|
||
constructor(private _element: ElementRef, private _overlay: Overlay, | ||
private _vcr: ViewContainerRef) {} | ||
|
||
ngOnDestroy() { this.destroyPanel(); } | ||
|
||
/* Whether or not the autocomplete panel is open. */ | ||
get panelOpen(): boolean { | ||
return this._panelOpen; | ||
} | ||
|
||
/** Opens the autocomplete suggestion panel. */ | ||
openPanel(): void { | ||
if (!this._overlayRef) { | ||
this._createOverlay(); | ||
} | ||
|
||
if (!this._overlayRef.hasAttached()) { | ||
this._overlayRef.attach(this._portal); | ||
this._watchForClose(); | ||
} | ||
|
||
this._panelOpen = true; | ||
} | ||
|
||
/** Closes the autocomplete suggestion panel. */ | ||
closePanel(): void { | ||
if (this._overlayRef && this._overlayRef.hasAttached()) { | ||
this._overlayRef.detach(); | ||
} | ||
|
||
this._closeWatcher.unsubscribe(); | ||
this._panelOpen = false; | ||
} | ||
|
||
/** Destroys the autocomplete suggestion panel. */ | ||
destroyPanel(): void { | ||
if (this._overlayRef) { | ||
this.closePanel(); | ||
this._overlayRef.dispose(); | ||
this._overlayRef = null; | ||
} | ||
} | ||
|
||
/** | ||
* This method will close the panel if it receives a selection event from any of the options | ||
* or a click on the backdrop. | ||
*/ | ||
private _watchForClose() { | ||
// TODO(kara): add tab event watcher when adding keyboard events | ||
this._closeWatcher = Observable.merge(...this._getOptionObs(), this._overlayRef.backdropClick()) | ||
.subscribe(() => this.closePanel()); | ||
} | ||
|
||
/** | ||
* This method maps all the autocomplete's child options into a flattened list | ||
* of their selection events. This map will be used to merge the option events and | ||
* the backdrop click into one observable. | ||
*/ | ||
private _getOptionObs(): Observable<any>[] { | ||
return this.autocomplete.options.map((option) => option.onSelect); | ||
} | ||
|
||
private _createOverlay(): void { | ||
this._portal = new TemplatePortal(this.autocomplete.template, this._vcr); | ||
this._overlayRef = this._overlay.create(this._getOverlayConfig()); | ||
} | ||
|
||
private _getOverlayConfig(): OverlayState { | ||
const overlayState = new OverlayState(); | ||
overlayState.positionStrategy = this._getOverlayPosition(); | ||
overlayState.width = this._getHostWidth(); | ||
overlayState.hasBackdrop = true; | ||
overlayState.backdropClass = 'md-overlay-transparent-backdrop'; | ||
return overlayState; | ||
} | ||
|
||
private _getOverlayPosition(): PositionStrategy { | ||
return this._overlay.position().connectedTo( | ||
this._element, | ||
{originX: 'start', originY: 'bottom'}, {overlayX: 'start', overlayY: 'top'}) | ||
.withOffsetY(MD_AUTOCOMPLETE_PANEL_OFFSET); | ||
} | ||
|
||
/** Returns the width of the input element, so the panel width can match it. */ | ||
private _getHostWidth(): number { | ||
return this._element.nativeElement.getBoundingClientRect().width; | ||
} | ||
|
||
} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,5 @@ | ||
I'm an autocomplete! | ||
<template> | ||
<div class="md-autocomplete-panel"> | ||
<ng-content></ng-content> | ||
</div> | ||
</template> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
@import '../core/style/menu-common'; | ||
|
||
.md-autocomplete-panel { | ||
@include md-menu-base(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,29 +1,188 @@ | ||
import {TestBed, async} from '@angular/core/testing'; | ||
import {Component} from '@angular/core'; | ||
import {MdAutocompleteModule} from './index'; | ||
import {TestBed, async, ComponentFixture} from '@angular/core/testing'; | ||
import {Component, ViewChild} from '@angular/core'; | ||
import {By} from '@angular/platform-browser'; | ||
import {MdAutocompleteModule, MdAutocompleteTrigger} from './index'; | ||
import {OverlayContainer} from '../core/overlay/overlay-container'; | ||
import {MdInputModule} from '../input/index'; | ||
|
||
describe('MdAutocomplete', () => { | ||
let overlayContainerElement: HTMLElement; | ||
|
||
beforeEach(async(() => { | ||
TestBed.configureTestingModule({ | ||
imports: [MdAutocompleteModule.forRoot()], | ||
imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()], | ||
declarations: [SimpleAutocomplete], | ||
providers: [] | ||
providers: [ | ||
{provide: OverlayContainer, useFactory: () => { | ||
overlayContainerElement = document.createElement('div'); | ||
|
||
// add fixed positioning to match real overlay container styles | ||
overlayContainerElement.style.position = 'fixed'; | ||
overlayContainerElement.style.top = '0'; | ||
overlayContainerElement.style.left = '0'; | ||
document.body.appendChild(overlayContainerElement); | ||
|
||
// remove body padding to keep consistent cross-browser | ||
document.body.style.padding = '0'; | ||
document.body.style.margin = '0'; | ||
|
||
return {getContainerElement: () => overlayContainerElement}; | ||
}}, | ||
] | ||
}); | ||
|
||
TestBed.compileComponents(); | ||
})); | ||
|
||
it('should have a test', () => { | ||
expect(true).toBe(true); | ||
describe('panel toggling', () => { | ||
let fixture: ComponentFixture<SimpleAutocomplete>; | ||
let trigger: HTMLElement; | ||
|
||
beforeEach(() => { | ||
fixture = TestBed.createComponent(SimpleAutocomplete); | ||
fixture.detectChanges(); | ||
|
||
trigger = fixture.debugElement.query(By.css('input')).nativeElement; | ||
}); | ||
|
||
it('should open the panel when the input is focused', () => { | ||
expect(fixture.componentInstance.trigger.panelOpen).toBe(false); | ||
dispatchEvent('focus', trigger); | ||
fixture.detectChanges(); | ||
|
||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(true, `Expected panel state to read open when input is focused.`); | ||
expect(overlayContainerElement.textContent) | ||
.toContain('Alabama', `Expected panel to display when input is focused.`); | ||
expect(overlayContainerElement.textContent) | ||
.toContain('California', `Expected panel to display when input is focused.`); | ||
}); | ||
|
||
it('should open the panel programmatically', () => { | ||
expect(fixture.componentInstance.trigger.panelOpen).toBe(false); | ||
fixture.componentInstance.trigger.openPanel(); | ||
fixture.detectChanges(); | ||
|
||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(true, `Expected panel state to read open when opened programmatically.`); | ||
expect(overlayContainerElement.textContent) | ||
.toContain('Alabama', `Expected panel to display when opened programmatically.`); | ||
expect(overlayContainerElement.textContent) | ||
.toContain('California', `Expected panel to display when opened programmatically.`); | ||
}); | ||
|
||
it('should close the panel when a click occurs outside it', async(() => { | ||
dispatchEvent('focus', trigger); | ||
fixture.detectChanges(); | ||
|
||
const backdrop = <HTMLElement>overlayContainerElement.querySelector('.cdk-overlay-backdrop'); | ||
backdrop.click(); | ||
fixture.detectChanges(); | ||
|
||
fixture.whenStable().then(() => { | ||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(false, `Expected clicking outside the panel to set its state to closed.`); | ||
expect(overlayContainerElement.textContent) | ||
.toEqual('', `Expected clicking outside the panel to close the panel.`); | ||
}); | ||
})); | ||
|
||
it('should close the panel when an option is clicked', async(() => { | ||
dispatchEvent('focus', trigger); | ||
fixture.detectChanges(); | ||
|
||
const option = <HTMLElement>overlayContainerElement.querySelector('md-option'); | ||
option.click(); | ||
fixture.detectChanges(); | ||
|
||
fixture.whenStable().then(() => { | ||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(false, `Expected clicking an option to set the panel state to closed.`); | ||
expect(overlayContainerElement.textContent) | ||
.toEqual('', `Expected clicking an option to close the panel.`); | ||
}); | ||
})); | ||
|
||
it('should close the panel when a newly created option is clicked', async(() => { | ||
fixture.componentInstance.states.unshift({code: 'TEST', name: 'test'}); | ||
fixture.detectChanges(); | ||
|
||
dispatchEvent('focus', trigger); | ||
fixture.detectChanges(); | ||
|
||
const option = <HTMLElement>overlayContainerElement.querySelector('md-option'); | ||
option.click(); | ||
fixture.detectChanges(); | ||
|
||
fixture.whenStable().then(() => { | ||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(false, `Expected clicking a new option to set the panel state to closed.`); | ||
expect(overlayContainerElement.textContent) | ||
.toEqual('', `Expected clicking a new option to close the panel.`); | ||
}); | ||
})); | ||
|
||
it('should close the panel programmatically', async(() => { | ||
fixture.componentInstance.trigger.openPanel(); | ||
fixture.detectChanges(); | ||
|
||
fixture.componentInstance.trigger.closePanel(); | ||
fixture.detectChanges(); | ||
|
||
fixture.whenStable().then(() => { | ||
expect(fixture.componentInstance.trigger.panelOpen) | ||
.toBe(false, `Expected closing programmatically to set the panel state to closed.`); | ||
expect(overlayContainerElement.textContent) | ||
.toEqual('', `Expected closing programmatically to close the panel.`); | ||
}); | ||
})); | ||
|
||
}); | ||
|
||
}); | ||
|
||
@Component({ | ||
template: ` | ||
<md-autocomplete></md-autocomplete> | ||
<md-input-container> | ||
<input mdInput placeholder="State" [mdAutocomplete]="auto"> | ||
</md-input-container> | ||
<md-autocomplete #auto="mdAutocomplete"> | ||
<md-option *ngFor="let state of states" [value]="state.code"> {{ state.name }} </md-option> | ||
</md-autocomplete> | ||
` | ||
}) | ||
class SimpleAutocomplete {} | ||
class SimpleAutocomplete { | ||
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger; | ||
|
||
states = [ | ||
{code: 'AL', name: 'Alabama'}, | ||
{code: 'CA', name: 'California'}, | ||
{code: 'FL', name: 'Florida'}, | ||
{code: 'KS', name: 'Kansas'}, | ||
{code: 'MA', name: 'Massachusetts'}, | ||
{code: 'NY', name: 'New York'}, | ||
{code: 'OR', name: 'Oregon'}, | ||
{code: 'PA', name: 'Pennsylvania'}, | ||
{code: 'TN', name: 'Tennessee'}, | ||
{code: 'VA', name: 'Virginia'}, | ||
{code: 'WY', name: 'Wyoming'}, | ||
]; | ||
} | ||
|
||
|
||
/** | ||
* TODO: Move this to core testing utility until Angular has event faking | ||
* support. | ||
* | ||
* Dispatches an event from an element. | ||
* @param eventName Name of the event | ||
* @param element The element from which the event will be dispatched. | ||
*/ | ||
function dispatchEvent(eventName: string, element: HTMLElement): void { | ||
let event = document.createEvent('Event'); | ||
event.initEvent(eventName, true, true); | ||
element.dispatchEvent(event); | ||
} | ||
|
||
|
Oops, something went wrong.