Skip to content

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 1 commit into from
Jan 7, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
Copy link
Member

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

Copy link
Contributor Author

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.


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