Skip to content

Commit

Permalink
feat(autocomplete): add autocomplete panel toggling (#2452)
Browse files Browse the repository at this point in the history
  • Loading branch information
kara authored Jan 7, 2017
1 parent 55e5686 commit d4ab3d3
Show file tree
Hide file tree
Showing 21 changed files with 461 additions and 76 deletions.
8 changes: 7 additions & 1 deletion src/demo-app/autocomplete/autocomplete-demo.html
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>
31 changes: 30 additions & 1 deletion src/demo-app/autocomplete/autocomplete-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,33 @@ import {Component} from '@angular/core';
templateUrl: 'autocomplete-demo.html',
styleUrls: ['autocomplete-demo.css'],
})
export class AutocompleteDemo {}
export class AutocompleteDemo {
states = [
{code: 'AL', name: 'Alabama'},
{code: 'AZ', name: 'Arizona'},
{code: 'CA', name: 'California'},
{code: 'CO', name: 'Colorado'},
{code: 'CT', name: 'Connecticut'},
{code: 'FL', name: 'Florida'},
{code: 'GA', name: 'Georgia'},
{code: 'ID', name: 'Idaho'},
{code: 'KS', name: 'Kansas'},
{code: 'LA', name: 'Louisiana'},
{code: 'MA', name: 'Massachusetts'},
{code: 'MN', name: 'Minnesota'},
{code: 'MI', name: 'Mississippi'},
{code: 'NY', name: 'New York'},
{code: 'NC', name: 'North Carolina'},
{code: 'OK', name: 'Oklahoma'},
{code: 'OH', name: 'Ohio'},
{code: 'OR', name: 'Oregon'},
{code: 'PA', name: 'Pennsylvania'},
{code: 'SC', name: 'South Carolina'},
{code: 'TN', name: 'Tennessee'},
{code: 'TX', name: 'Texas'},
{code: 'VA', name: 'Virginia'},
{code: 'WA', name: 'Washington'},
{code: 'WI', name: 'Wisconsin'},
{code: 'WY', name: 'Wyoming'},
];
}
11 changes: 11 additions & 0 deletions src/lib/autocomplete/_autocomplete-theme.scss
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);
}
}
}
114 changes: 114 additions & 0 deletions src/lib/autocomplete/autocomplete-trigger.ts
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;
}

}

6 changes: 5 additions & 1 deletion src/lib/autocomplete/autocomplete.html
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>
5 changes: 5 additions & 0 deletions src/lib/autocomplete/autocomplete.scss
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();
}
173 changes: 164 additions & 9 deletions src/lib/autocomplete/autocomplete.spec.ts
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);
}


Loading

0 comments on commit d4ab3d3

Please sign in to comment.