mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(autocomplete): add keyboard events to autocomplete (#2723)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -19,14 +21,19 @@ 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;
|
||||
|
||||
@@ -34,6 +41,10 @@ export class MdAutocompleteTrigger implements OnDestroy {
|
||||
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. */
|
||||
@@ -69,8 +80,11 @@ 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. */
|
||||
@@ -78,6 +92,19 @@ export class MdAutocompleteTrigger implements OnDestroy {
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -7,6 +7,8 @@ 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;
|
||||
@@ -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', () => {
|
||||
@@ -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({
|
||||
@@ -257,6 +412,7 @@ class SimpleAutocomplete implements OnDestroy {
|
||||
valueSub: Subscription;
|
||||
|
||||
@ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger;
|
||||
@ViewChildren(MdOption) options: QueryList<MdOption>;
|
||||
|
||||
states = [
|
||||
{code: 'AL', name: 'Alabama'},
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()',
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user