-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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 (#2452)
- Loading branch information
Showing
21 changed files
with
461 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,114 @@ | ||
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; | ||
|
||
/** The subscription to events that close the autocomplete panel. */ | ||
private _closingActionsSubscription: Subscription; | ||
|
||
/* The autocomplete panel to be attached to this trigger. */ | ||
@Input('mdAutocomplete') autocomplete: MdAutocomplete; | ||
|
||
constructor(private _element: ElementRef, private _overlay: Overlay, | ||
private _viewContainerRef: 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._closingActionsSubscription = | ||
this.panelClosingActions.subscribe(() => this.closePanel()); | ||
} | ||
|
||
this._panelOpen = true; | ||
} | ||
|
||
/** Closes the autocomplete suggestion panel. */ | ||
closePanel(): void { | ||
if (this._overlayRef && this._overlayRef.hasAttached()) { | ||
this._overlayRef.detach(); | ||
} | ||
|
||
this._closingActionsSubscription.unsubscribe(); | ||
this._panelOpen = false; | ||
} | ||
|
||
/** | ||
* A stream of actions that should close the autocomplete panel, including | ||
* when an option is selected and when the backdrop is clicked. | ||
*/ | ||
get panelClosingActions(): Observable<any> { | ||
// TODO(kara): add tab event observable with keyboard event PR | ||
return Observable.merge(...this.optionSelections, this._overlayRef.backdropClick()); | ||
} | ||
|
||
/** Stream of autocomplete option selections. */ | ||
get optionSelections(): Observable<any>[] { | ||
return this.autocomplete.options.map(option => option.onSelect); | ||
} | ||
|
||
/** Destroys the autocomplete suggestion panel. */ | ||
private _destroyPanel(): void { | ||
if (this._overlayRef) { | ||
this.closePanel(); | ||
this._overlayRef.dispose(); | ||
this._overlayRef = null; | ||
} | ||
} | ||
|
||
private _createOverlay(): void { | ||
this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef); | ||
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,184 @@ | ||
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'); | ||
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 = | ||
overlayContainerElement.querySelector('.cdk-overlay-backdrop') as HTMLElement; | ||
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 = overlayContainerElement.querySelector('md-option') as HTMLElement; | ||
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 = overlayContainerElement.querySelector('md-option') as HTMLElement; | ||
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.