Skip to content

Commit

Permalink
feat(autocomplete): add keyboard events to autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Jan 13, 2017
1 parent 8d0d22a commit f466aca
Show file tree
Hide file tree
Showing 5 changed files with 229 additions and 28 deletions.
2 changes: 1 addition & 1 deletion src/lib/autocomplete/_autocomplete-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
}

md-option {
&.md-selected {
&.md-selected:not(.md-active) {
background: md-color($background, card);
color: md-color($foreground, text);
}
Expand Down
49 changes: 42 additions & 7 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {
Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
AfterContentInit, Directive, ElementRef, Input, ViewContainerRef, Optional, OnDestroy
} from '@angular/core';
import {NgControl} from '@angular/forms';
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 {MdOptionSelectEvent} from '../core/option/option';
import {MdOptionSelectEvent, MdOption} from '../core/option/option';
import {ActiveDescendantKeyManager} from '../core/a11y/activedescendant-key-manager';
import {ENTER} from '../core/keyboard/keycodes';
import 'rxjs/add/observable/merge';
import 'rxjs/add/operator/startWith';
import 'rxjs/add/operator/switchMap';
Expand All @@ -17,21 +19,30 @@ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6;
@Directive({
selector: 'input[mdAutocomplete], input[matAutocomplete]',
host: {
'(focus)': 'openPanel()'
'(focus)': 'openPanel()',
'(keydown)': '_handleKeydown($event)',
'autocomplete': 'off'
}
})
export class MdAutocompleteTrigger implements OnDestroy {
export class MdAutocompleteTrigger implements AfterContentInit, OnDestroy {
private _overlayRef: OverlayRef;
private _portal: TemplatePortal;
private _panelOpen: boolean = false;

/** Manages active item in option list based on key events. */
private _keyManager: ActiveDescendantKeyManager;

/* The autocomplete panel to be attached to this trigger. */
@Input('mdAutocomplete') autocomplete: MdAutocomplete;

constructor(private _element: ElementRef, private _overlay: Overlay,
private _viewContainerRef: ViewContainerRef,
@Optional() private _controlDir: NgControl) {}

ngAfterContentInit() {
this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options);
}

ngOnDestroy() { this._destroyPanel(); }

/* Whether or not the autocomplete panel is open. */
Expand Down Expand Up @@ -67,15 +78,31 @@ export class MdAutocompleteTrigger implements OnDestroy {
* 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());
return Observable.merge(
...this.optionSelections,
this._overlayRef.backdropClick(),
this._keyManager.tabOut
);
}

/** Stream of autocomplete option selections. */
get optionSelections(): Observable<any>[] {
return this.autocomplete.options.map(option => option.onSelect);
}

/** The currently active option, coerced to MdOption type. */
get activeOption(): MdOption {
return this._keyManager.activeItem as MdOption;
}

_handleKeydown(event: KeyboardEvent): void {
if (this.activeOption && event.keyCode === ENTER) {
this.activeOption._selectViaInteraction();
} else {
this.openPanel();
this._keyManager.onKeydown(event);
}
}

/**
* This method listens to a stream of panel closing actions and resets the
Expand All @@ -88,7 +115,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
.startWith(null)
// create a new stream of panelClosingActions, replacing any previous streams
// that were created, and flatten it so our stream only emits closing events...
.switchMap(() => this.panelClosingActions)
.switchMap(() => {
this._resetActiveItem();
return this.panelClosingActions;
})
// when the first closing event occurs...
.first()
// set the value, close the panel, and complete.
Expand Down Expand Up @@ -146,5 +176,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
return this._element.nativeElement.getBoundingClientRect().width;
}

/** Reset active item to -1 so DOWN_ARROW event will activate the first option.*/
private _resetActiveItem(): void {
this._keyManager.setActiveItem(-1);
}

}

171 changes: 151 additions & 20 deletions src/lib/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {TestBed, async, ComponentFixture} from '@angular/core/testing';
import {Component, OnDestroy, ViewChild} from '@angular/core';
import {Component, OnDestroy, QueryList, ViewChild, ViewChildren} 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';
import {FormControl, ReactiveFormsModule} from '@angular/forms';
import {Subscription} from 'rxjs/Subscription';
import {ENTER, DOWN_ARROW, SPACE} from '../core/keyboard/keycodes';
import {MdOption} from '../core/option/option';

describe('MdAutocomplete', () => {
let overlayContainerElement: HTMLElement;
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;

beforeEach(async(() => {
TestBed.configureTestingModule({
Expand All @@ -33,17 +37,14 @@ describe('MdAutocomplete', () => {
TestBed.compileComponents();
}));

describe('panel toggling', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;

beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
fixture.detectChanges();
beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
fixture.detectChanges();

input = fixture.debugElement.query(By.css('input')).nativeElement;
});
input = fixture.debugElement.query(By.css('input')).nativeElement;
});

describe('panel toggling', () => {
it('should open the panel when the input is focused', () => {
expect(fixture.componentInstance.trigger.panelOpen).toBe(false);
dispatchEvent('focus', input);
Expand Down Expand Up @@ -165,15 +166,6 @@ describe('MdAutocomplete', () => {
});

describe('forms integration', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;

beforeEach(() => {
fixture = TestBed.createComponent(SimpleAutocomplete);
fixture.detectChanges();

input = fixture.debugElement.query(By.css('input')).nativeElement;
});

it('should fill the text field when an option is selected', () => {
fixture.componentInstance.trigger.openPanel();
Expand All @@ -185,7 +177,7 @@ describe('MdAutocomplete', () => {
fixture.detectChanges();

expect(input.value)
.toContain('California', `Expected text field to be filled with selected value.`);
.toContain('California', `Expected text field to fill with selected value.`);
});

it('should mark the autocomplete control as dirty when an option is selected', () => {
Expand Down Expand Up @@ -216,6 +208,137 @@ describe('MdAutocomplete', () => {

});

describe('keyboard events', () => {
let DOWN_ARROW_EVENT: KeyboardEvent;
let ENTER_EVENT: KeyboardEvent;

beforeEach(() => {
DOWN_ARROW_EVENT = new FakeKeyboardEvent(DOWN_ARROW) as KeyboardEvent;
ENTER_EVENT = new FakeKeyboardEvent(ENTER) as KeyboardEvent;
});

it('should should not focus the option when DOWN key is pressed', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

spyOn(fixture.componentInstance.options.first, 'focus');

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled();
});

it('should set the active item to the first option when DOWN key is pressed', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

const optionEls =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();

expect(fixture.componentInstance.trigger.activeOption)
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
expect(optionEls[0].classList).toContain('md-active');
expect(optionEls[1].classList).not.toContain('md-active');

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();

expect(fixture.componentInstance.trigger.activeOption)
.toBe(fixture.componentInstance.options.toArray()[1],
'Expected second option to be active.');
expect(optionEls[0].classList).not.toContain('md-active');
expect(optionEls[1].classList).toContain('md-active');
});

it('should set the active item properly after filtering', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();

input.value = 'o';
dispatchEvent('input', input);
fixture.detectChanges();

const optionEls =
overlayContainerElement.querySelectorAll('md-option') as NodeListOf<HTMLElement>;

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.detectChanges();

expect(fixture.componentInstance.trigger.activeOption)
.toBe(fixture.componentInstance.options.first, 'Expected first option to be active.');
expect(optionEls[0].classList).toContain('md-active');
expect(optionEls[1].classList).not.toContain('md-active');
});

it('should fill the text field when an option is selected with ENTER', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
fixture.detectChanges();

expect(input.value)
.toContain('Alabama', `Expected text field to fill with selected value on ENTER.`);
});

it('should fill the text field, not select an option, when SPACE is entered', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

input.value = 'New';
dispatchEvent('input', input);
fixture.detectChanges();

const SPACE_EVENT = new FakeKeyboardEvent(SPACE) as KeyboardEvent;
fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT);
fixture.detectChanges();

expect(input.value)
.not.toContain('New York', `Expected option not to be selected on SPACE.`);
});

it('should mark the control as dirty when an option is selected from the keyboard', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

expect(fixture.componentInstance.stateCtrl.dirty)
.toBe(false, `Expected control to start out pristine.`);

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
fixture.detectChanges();

expect(fixture.componentInstance.stateCtrl.dirty)
.toBe(true, `Expected control to become dirty when option was selected by ENTER.`);
});

it('should open the panel again when typing after making a selection', () => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);
fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT);
fixture.detectChanges();

input.value = 'a';
dispatchEvent('input', input);
fixture.detectChanges();

expect(fixture.componentInstance.trigger.panelOpen)
.toBe(true, `Expected panel state to read open when typing in input.`);
expect(overlayContainerElement.textContent)
.toContain('Alabama', `Expected panel to display when typing in input.`);
});

});

});

@Component({
Expand All @@ -237,6 +360,7 @@ class SimpleAutocomplete implements OnDestroy {
valueSub: Subscription;

@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
@ViewChildren(MdOption) options: QueryList<MdOption>;

states = [
{code: 'AL', name: 'Alabama'},
Expand Down Expand Up @@ -281,4 +405,11 @@ function dispatchEvent(eventName: string, element: HTMLElement): void {
element.dispatchEvent(event);
}

/** This is a mock keyboard event to test keyboard events in the autocomplete. */
class FakeKeyboardEvent {
constructor(public keyCode: number) {}
preventDefault() {}
}



5 changes: 5 additions & 0 deletions src/lib/core/option/_option-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
color: md-color($primary);
}

&.md-active {
background: md-color($background, hover);
color: md-color($foreground, text);
}

&.md-option-disabled {
color: md-color($foreground, hint-text);
}
Expand Down
Loading

0 comments on commit f466aca

Please sign in to comment.