-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(autocomplete): add autocomplete panel toggling #2452
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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 hidden or 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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think these styles are necessary any more since 38700e0
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh right! Forgot to update.