Permalink
Browse files

feat(autocomplete): add autocomplete panel toggling (#2452)

  • Loading branch information...
1 parent 55e5686 commit d4ab3d3feab4eabf5dbd869ca2af968380301305 @kara kara committed on GitHub Jan 7, 2017
@@ -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>
@@ -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'},
+ ];
+}
@@ -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);
+ }
+ }
}
@@ -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;
+ }
+
+}
+
@@ -1 +1,5 @@
-I'm an autocomplete!
+<template>
+ <div class="md-autocomplete-panel">
+ <ng-content></ng-content>
+ </div>
+</template>
@@ -0,0 +1,5 @@
+@import '../core/style/menu-common';
+
+.md-autocomplete-panel {
+ @include md-menu-base();
+}
@@ -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.

0 comments on commit d4ab3d3

Please sign in to comment.