Skip to content
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

feat(autocomplete): add keyboard events to autocomplete #2723

Merged
merged 1 commit into from
Jan 23, 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
2 changes: 1 addition & 1 deletion src/lib/autocomplete/_autocomplete-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
color: md-color($foreground, text);

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 {Dir} from '../core/rtl/dir';
import 'rxjs/add/operator/startWith';
Expand All @@ -19,21 +21,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, @Optional() private _dir: Dir) {}

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

ngOnDestroy() { this._destroyPanel(); }

/* Whether or not the autocomplete panel is open. */
Expand Down Expand Up @@ -69,15 +80,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 @@ -90,7 +117,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 @@ -149,5 +179,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);
}

}

167 changes: 165 additions & 2 deletions src/lib/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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 {Dir, LayoutDirection} from '../core/rtl/dir';
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;
Expand Down Expand Up @@ -205,7 +207,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 @@ -236,6 +238,159 @@ describe('MdAutocomplete', () => {

});

describe('keyboard events', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;
let input: HTMLInputElement;
let DOWN_ARROW_EVENT: KeyboardEvent;
let ENTER_EVENT: KeyboardEvent;

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

input = fixture.debugElement.query(By.css('input')).nativeElement;
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', async(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

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

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);

fixture.whenStable().then(() => {
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.whenStable().then(() => {
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', async(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT);

fixture.whenStable().then(() => {
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.whenStable().then(() => {
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', async(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

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

fixture.whenStable().then(() => {
expect(fixture.componentInstance.trigger.panelOpen)
.toBe(false, `Expected panel state to read closed after ENTER key.`);
expect(overlayContainerElement.textContent)
.toEqual('', `Expected panel to close after ENTER key.`);

// 65 is the keycode for "a"
const A_KEY = new FakeKeyboardEvent(65) as KeyboardEvent;
fixture.componentInstance.trigger._handleKeydown(A_KEY);
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 @@ -257,6 +412,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 @@ -301,4 +457,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
30 changes: 30 additions & 0 deletions src/lib/core/option/option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export class MdOptionSelectEvent {
'role': 'option',
'[attr.tabindex]': '_getTabIndex()',
'[class.md-selected]': 'selected',
'[class.md-active]': 'active',
'[id]': 'id',
'[attr.aria-selected]': 'selected.toString()',
'[attr.aria-disabled]': 'disabled.toString()',
Expand All @@ -48,6 +49,7 @@ export class MdOptionSelectEvent {
})
export class MdOption {
private _selected: boolean = false;
private _active: boolean = false;

/** Whether the option is disabled. */
private _disabled: boolean = false;
Expand Down Expand Up @@ -75,6 +77,16 @@ export class MdOption {
return this._selected;
}

/**
* Whether or not the option is currently active and ready to be selected.
* An active option displays styles as if it is focused, but the
* focus is actually retained somewhere else. This comes in handy
* for components like autocomplete where focus must remain on the input.
*/
get active(): boolean {
return this._active;
}

/**
* The displayed value of the option. It is necessary to show the selected option in the
* select's trigger.
Expand All @@ -100,6 +112,24 @@ export class MdOption {
this._renderer.invokeElementMethod(this._getHostElement(), 'focus');
}

/**
* This method sets display styles on the option to make it appear
* active. This is used by the ActiveDescendantKeyManager so key
* events will display the proper options as active on arrow key events.
*/
setActiveStyles() {
Promise.resolve(null).then(() => this._active = true);
}

/**
* This method removes display styles on the option that made it appear
* active. This is used by the ActiveDescendantKeyManager so key
* events will display the proper options as active on arrow key events.
*/
setInactiveStyles() {
Promise.resolve(null).then(() => this._active = false);
}

/** Ensures the option is selected when activated from the keyboard. */
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
Expand Down