diff --git a/src/demo-app/autocomplete/autocomplete-demo.html b/src/demo-app/autocomplete/autocomplete-demo.html index 3ab4a1eaabd5..95e6a345b179 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.html +++ b/src/demo-app/autocomplete/autocomplete-demo.html @@ -1,9 +1,52 @@
- - - + +
Reactive value: {{ stateCtrl.value }}
+
Reactive dirty: {{ stateCtrl.dirty }}
- - {{ state.name }} - + + + + + + + + + + +
+ + +
Template-driven value (currentState): {{ currentState }}
+
Template-driven dirty: {{ modelDir.dirty }}
+ + + + + + + + + + + +
+ + + + {{ state.name }} + ({{state.code}}) + + + + + + {{ state.name }} + ({{state.code}}) + + \ No newline at end of file diff --git a/src/demo-app/autocomplete/autocomplete-demo.scss b/src/demo-app/autocomplete/autocomplete-demo.scss index 94c86ec8589d..5789ae0ee434 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.scss +++ b/src/demo-app/autocomplete/autocomplete-demo.scss @@ -1 +1,18 @@ -.demo-autocomplete {} +.demo-autocomplete { + display: flex; + flex-flow: row wrap; + + md-card { + width: 350px; + margin: 24px; + } + + md-input-container { + margin-top: 16px; + } +} + +.demo-secondary-text { + color: rgba(0, 0, 0, 0.54); + margin-left: 8px; +} diff --git a/src/demo-app/autocomplete/autocomplete-demo.ts b/src/demo-app/autocomplete/autocomplete-demo.ts index c06a099fd343..50ae3077dc4d 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.ts +++ b/src/demo-app/autocomplete/autocomplete-demo.ts @@ -1,12 +1,24 @@ -import {Component} from '@angular/core'; +import {Component, OnDestroy, ViewEncapsulation} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {Subscription} from 'rxjs/Subscription'; @Component({ moduleId: module.id, selector: 'autocomplete-demo', templateUrl: 'autocomplete-demo.html', styleUrls: ['autocomplete-demo.css'], + encapsulation: ViewEncapsulation.None }) -export class AutocompleteDemo { +export class AutocompleteDemo implements OnDestroy { + stateCtrl = new FormControl(); + currentState = ''; + + reactiveStates: any[]; + tdStates: any[]; + + reactiveValueSub: Subscription; + tdDisabled = false; + states = [ {code: 'AL', name: 'Alabama'}, {code: 'AZ', name: 'Arizona'}, @@ -35,4 +47,21 @@ export class AutocompleteDemo { {code: 'WI', name: 'Wisconsin'}, {code: 'WY', name: 'Wyoming'}, ]; + + constructor() { + this.reactiveStates = this.states; + this.tdStates = this.states; + this.reactiveValueSub = + this.stateCtrl.valueChanges.subscribe(val => this.reactiveStates = this.filterStates(val)); + + } + + filterStates(val: string) { + return val ? this.states.filter((s) => s.name.match(new RegExp(val, 'gi'))) : this.states; + } + + ngOnDestroy() { + this.reactiveValueSub.unsubscribe(); + } + } diff --git a/src/lib/autocomplete/_autocomplete-theme.scss b/src/lib/autocomplete/_autocomplete-theme.scss index 5d0493df039c..5177b7c8f42b 100644 --- a/src/lib/autocomplete/_autocomplete-theme.scss +++ b/src/lib/autocomplete/_autocomplete-theme.scss @@ -4,11 +4,13 @@ $foreground: map-get($theme, foreground); $background: map-get($theme, background); - md-option { + .md-autocomplete-panel { background: md-color($background, card); color: md-color($foreground, text); + } - &.md-selected { + md-option { + &.md-selected:not(.md-active) { background: md-color($background, card); color: md-color($foreground, text); } diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index dfe1967149ab..b41b287d9fa6 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -1,10 +1,17 @@ -import {Directive, ElementRef, Input, ViewContainerRef, OnDestroy} from '@angular/core'; +import { + 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 {Subscription} from 'rxjs/Subscription'; +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'; /** The panel needs a slight y-offset to ensure the input underline displays. */ export const MD_AUTOCOMPLETE_PANEL_OFFSET = 6; @@ -12,22 +19,29 @@ 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; - /** The subscription to events that close the autocomplete panel. */ - private _closingActionsSubscription: Subscription; + /** 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) {} + private _viewContainerRef: ViewContainerRef, + @Optional() private _controlDir: NgControl) {} + + ngAfterContentInit() { + this._keyManager = new ActiveDescendantKeyManager(this.autocomplete.options); + } ngOnDestroy() { this._destroyPanel(); } @@ -44,8 +58,7 @@ export class MdAutocompleteTrigger implements OnDestroy { if (!this._overlayRef.hasAttached()) { this._overlayRef.attach(this._portal); - this._closingActionsSubscription = - this.panelClosingActions.subscribe(() => this.closePanel()); + this._subscribeToClosingActions(); } this._panelOpen = true; @@ -57,7 +70,6 @@ export class MdAutocompleteTrigger implements OnDestroy { this._overlayRef.detach(); } - this._closingActionsSubscription.unsubscribe(); this._panelOpen = false; } @@ -66,8 +78,11 @@ export class MdAutocompleteTrigger implements OnDestroy { * when an option is selected and when the backdrop is clicked. */ get panelClosingActions(): Observable { - // 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. */ @@ -75,6 +90,41 @@ 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 + * stream every time the option list changes. + */ + private _subscribeToClosingActions(): void { + // Every time the option list changes... + this.autocomplete.options.changes + // and also at initialization, before there are any option changes... + .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._resetActiveItem(); + return this.panelClosingActions; + }) + // when the first closing event occurs... + .first() + // set the value, close the panel, and complete. + .subscribe(event => this._setValueAndClose(event)); + } + /** Destroys the autocomplete suggestion panel. */ private _destroyPanel(): void { if (this._overlayRef) { @@ -84,6 +134,22 @@ export class MdAutocompleteTrigger implements OnDestroy { } } + /** + * This method closes the panel, and if a value is specified, also sets the associated + * control to that value. It will also mark the control as dirty if this interaction + * stemmed from the user. + */ + private _setValueAndClose(event: MdOptionSelectEvent | null): void { + if (event) { + this._controlDir.control.setValue(event.source.value); + if (event.isUserInput) { + this._controlDir.control.markAsDirty(); + } + } + + this.closePanel(); + } + private _createOverlay(): void { this._portal = new TemplatePortal(this.autocomplete.template, this._viewContainerRef); this._overlayRef = this._overlay.create(this._getOverlayConfig()); @@ -110,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); + } + } diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index e7db384a3fbf..d4c319bbafb8 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -1,16 +1,24 @@ import {TestBed, async, ComponentFixture} from '@angular/core/testing'; -import {Component, 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; + let input: HTMLInputElement; beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdAutocompleteModule.forRoot(), MdInputModule.forRoot()], + imports: [ + MdAutocompleteModule.forRoot(), MdInputModule.forRoot(), ReactiveFormsModule + ], declarations: [SimpleAutocomplete], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -29,20 +37,17 @@ describe('MdAutocomplete', () => { TestBed.compileComponents(); })); - describe('panel toggling', () => { - let fixture: ComponentFixture; - let trigger: HTMLElement; - - beforeEach(() => { - fixture = TestBed.createComponent(SimpleAutocomplete); - fixture.detectChanges(); + beforeEach(() => { + fixture = TestBed.createComponent(SimpleAutocomplete); + fixture.detectChanges(); - trigger = 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', trigger); + dispatchEvent('focus', input); fixture.detectChanges(); expect(fixture.componentInstance.trigger.panelOpen) @@ -67,7 +72,7 @@ describe('MdAutocomplete', () => { }); it('should close the panel when a click occurs outside it', async(() => { - dispatchEvent('focus', trigger); + dispatchEvent('focus', input); fixture.detectChanges(); const backdrop = @@ -84,7 +89,7 @@ describe('MdAutocomplete', () => { })); it('should close the panel when an option is clicked', async(() => { - dispatchEvent('focus', trigger); + dispatchEvent('focus', input); fixture.detectChanges(); const option = overlayContainerElement.querySelector('md-option') as HTMLElement; @@ -99,22 +104,47 @@ describe('MdAutocomplete', () => { }); })); - it('should close the panel when a newly created option is clicked', async(() => { - fixture.componentInstance.states.unshift({code: 'TEST', name: 'test'}); + it('should close the panel when a newly filtered option is clicked', async(() => { + dispatchEvent('focus', input); fixture.detectChanges(); - dispatchEvent('focus', trigger); + // Filter down the option list to a subset of original options ('Alabama', 'California') + input.value = 'al'; + dispatchEvent('input', input); fixture.detectChanges(); - const option = overlayContainerElement.querySelector('md-option') as HTMLElement; - option.click(); + let options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[0].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.`); + .toBe(false, `Expected clicking a filtered option to set the panel state to closed.`); expect(overlayContainerElement.textContent) - .toEqual('', `Expected clicking a new option to close the panel.`); + .toEqual('', `Expected clicking a filtered option to close the panel.`); + + dispatchEvent('focus', input); + fixture.detectChanges(); + + // Changing value from 'Alabama' to 'al' to re-populate the option list, + // ensuring that 'California' is created new. + input.value = 'al'; + dispatchEvent('input', input); + fixture.detectChanges(); + + options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[1].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.`); + }); + }); })); @@ -135,21 +165,218 @@ describe('MdAutocomplete', () => { }); + describe('forms integration', () => { + + it('should fill the text field when an option is selected', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(input.value) + .toContain('California', `Expected text field to fill with selected value.`); + }); + + it('should mark the autocomplete control as dirty when an option is selected', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + const options = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when an option was selected.`); + }); + + it('should not mark the control dirty when the value is set programmatically', () => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.stateCtrl.setValue('AL'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to stay pristine if value is set programmatically.`); + }); + + }); + + 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', async(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const optionEls = + overlayContainerElement.querySelectorAll('md-option') as NodeListOf; + + 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; + + 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({ template: ` - + - {{ state.name }} + + {{ state.name }} ({{ state.code }}) + ` }) -class SimpleAutocomplete { +class SimpleAutocomplete implements OnDestroy { + stateCtrl = new FormControl(); + filteredStates: any[]; + valueSub: Subscription; + @ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger; + @ViewChildren(MdOption) options: QueryList; states = [ {code: 'AL', name: 'Alabama'}, @@ -164,6 +391,19 @@ class SimpleAutocomplete { {code: 'VA', name: 'Virginia'}, {code: 'WY', name: 'Wyoming'}, ]; + + constructor() { + this.filteredStates = this.states; + this.valueSub = this.stateCtrl.valueChanges.subscribe(val => { + this.filteredStates = val ? this.states.filter((s) => s.name.match(new RegExp(val, 'gi'))) + : this.states; + }); + } + + ngOnDestroy() { + this.valueSub.unsubscribe(); + } + } @@ -181,4 +421,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() {} +} + + diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index 927fa1ab19e2..b683fa1c0e8b 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -2,7 +2,7 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component, DebugElement, QueryList} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdChip, MdChipList, MdChipsModule} from './index'; -import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {FakeEvent} from '../core/a11y/list-key-manager.spec'; import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes'; @@ -21,7 +21,7 @@ describe('MdChipList', () => { let chipListInstance: MdChipList; let testComponent: StaticChipList; let chips: QueryList; - let manager: ListKeyManager; + let manager: FocusKeyManager; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -60,7 +60,7 @@ describe('MdChipList', () => { chipListInstance.focus(); fixture.detectChanges(); - expect(manager.focusedItemIndex).toBe(0); + expect(manager.activeItemIndex).toBe(0); }); it('watches for chip focus', () => { @@ -71,7 +71,7 @@ describe('MdChipList', () => { lastItem.focus(); fixture.detectChanges(); - expect(manager.focusedItemIndex).toBe(lastIndex); + expect(manager.activeItemIndex).toBe(lastIndex); }); describe('on chip destroy', () => { @@ -87,7 +87,7 @@ describe('MdChipList', () => { fixture.detectChanges(); // It focuses the 4th item (now at index 2) - expect(manager.focusedItemIndex).toEqual(2); + expect(manager.activeItemIndex).toEqual(2); }); it('focuses the previous item', () => { @@ -103,7 +103,7 @@ describe('MdChipList', () => { fixture.detectChanges(); // It focuses the next-to-last item - expect(manager.focusedItemIndex).toEqual(lastIndex - 1); + expect(manager.activeItemIndex).toEqual(lastIndex - 1); }); }); }); @@ -124,14 +124,14 @@ describe('MdChipList', () => { // Focus the last item in the array lastItem.focus(); - expect(manager.focusedItemIndex).toEqual(lastIndex); + expect(manager.activeItemIndex).toEqual(lastIndex); // Press the LEFT arrow chipListInstance._keydown(LEFT_EVENT); fixture.detectChanges(); // It focuses the next-to-last item - expect(manager.focusedItemIndex).toEqual(lastIndex - 1); + expect(manager.activeItemIndex).toEqual(lastIndex - 1); }); it('right arrow focuses next item', () => { @@ -144,14 +144,14 @@ describe('MdChipList', () => { // Focus the last item in the array firstItem.focus(); - expect(manager.focusedItemIndex).toEqual(0); + expect(manager.activeItemIndex).toEqual(0); // Press the RIGHT arrow chipListInstance._keydown(RIGHT_EVENT); fixture.detectChanges(); // It focuses the next-to-last item - expect(manager.focusedItemIndex).toEqual(1); + expect(manager.activeItemIndex).toEqual(1); }); describe('when selectable is true', () => { diff --git a/src/lib/chips/chip-list.ts b/src/lib/chips/chip-list.ts index 3b4dea73c7d5..704d7b4d4ee6 100644 --- a/src/lib/chips/chip-list.ts +++ b/src/lib/chips/chip-list.ts @@ -12,7 +12,7 @@ import { } from '@angular/core'; import {MdChip} from './chip'; -import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {coerceBooleanProperty} from '../core/coercion/boolean-property'; import {SPACE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes'; @@ -55,8 +55,8 @@ export class MdChipList implements AfterContentInit { /** Whether or not the chip is selectable. */ protected _selectable: boolean = true; - /** The ListKeyManager which handles focus. */ - _keyManager: ListKeyManager; + /** The FocusKeyManager which handles focus. */ + _keyManager: FocusKeyManager; /** The chip components contained within this chip list. */ chips: QueryList; @@ -64,7 +64,7 @@ export class MdChipList implements AfterContentInit { constructor(private _elementRef: ElementRef) { } ngAfterContentInit(): void { - this._keyManager = new ListKeyManager(this.chips).withFocusWrap(); + this._keyManager = new FocusKeyManager(this.chips).withWrap(); // Go ahead and subscribe all of the initial chips this._subscribeChips(this.chips); @@ -93,7 +93,7 @@ export class MdChipList implements AfterContentInit { */ focus() { // TODO: ARIA says this should focus the first `selected` chip. - this._keyManager.focusFirstItem(); + this._keyManager.setFirstItemActive(); } /** Passes relevant key presses to our key manager. */ @@ -113,11 +113,11 @@ export class MdChipList implements AfterContentInit { event.preventDefault(); break; case LEFT_ARROW: - this._keyManager.focusPreviousItem(); + this._keyManager.setPreviousItemActive(); event.preventDefault(); break; case RIGHT_ARROW: - this._keyManager.focusNextItem(); + this._keyManager.setNextItemActive(); event.preventDefault(); break; default: @@ -133,7 +133,7 @@ export class MdChipList implements AfterContentInit { return; } - let focusedIndex = this._keyManager.focusedItemIndex; + let focusedIndex = this._keyManager.activeItemIndex; if (this._isValidIndex(focusedIndex)) { let focusedChip: MdChip = this.chips.toArray()[focusedIndex]; @@ -173,7 +173,7 @@ export class MdChipList implements AfterContentInit { let chipIndex: number = this.chips.toArray().indexOf(chip); if (this._isValidIndex(chipIndex)) { - this._keyManager.updateFocusedItemIndex(chipIndex); + this._keyManager.updateActiveItemIndex(chipIndex); } }); @@ -184,9 +184,9 @@ export class MdChipList implements AfterContentInit { if (this._isValidIndex(chipIndex)) { // Check whether the chip is the last item if (chipIndex < this.chips.length - 1) { - this._keyManager.setFocus(chipIndex); + this._keyManager.setActiveItem(chipIndex); } else if (chipIndex - 1 >= 0) { - this._keyManager.setFocus(chipIndex - 1); + this._keyManager.setActiveItem(chipIndex - 1); } } diff --git a/src/lib/chips/chip.ts b/src/lib/chips/chip.ts index 2c9fdae96fbc..905a90079329 100644 --- a/src/lib/chips/chip.ts +++ b/src/lib/chips/chip.ts @@ -9,7 +9,7 @@ import { Renderer } from '@angular/core'; -import {Focusable} from '../core/a11y/list-key-manager'; +import {Focusable} from '../core/a11y/focus-key-manager'; import {coerceBooleanProperty} from '../core/coercion/boolean-property'; export interface MdChipEvent { diff --git a/src/lib/core/a11y/activedescendant-key-manager.ts b/src/lib/core/a11y/activedescendant-key-manager.ts new file mode 100644 index 000000000000..0c9a49bec242 --- /dev/null +++ b/src/lib/core/a11y/activedescendant-key-manager.ts @@ -0,0 +1,35 @@ +import {QueryList} from '@angular/core'; +import {ListKeyManager, CanDisable} from './list-key-manager'; + +/** + * This is the interface for highlightable items (used by the ActiveDescendantKeyManager). + * Each item must know how to style itself as active or inactive and whether or not it is + * currently disabled. + */ +export interface Highlightable extends CanDisable { + setActiveStyles(): void; + setInactiveStyles(): void; +} + +export class ActiveDescendantKeyManager extends ListKeyManager { + + constructor(items: QueryList) { + super(items); + } + + /** + * This method sets the active item to the item at the specified index. + * It also adds active styles to the newly active item and removes active + * styles from the previously active item. + */ + setActiveItem(index: number): void { + if (this.activeItem) { + this.activeItem.setInactiveStyles(); + } + super.setActiveItem(index); + if (this.activeItem) { + this.activeItem.setActiveStyles(); + } + } + +} diff --git a/src/lib/core/a11y/focus-key-manager.ts b/src/lib/core/a11y/focus-key-manager.ts new file mode 100644 index 000000000000..d92160b07c15 --- /dev/null +++ b/src/lib/core/a11y/focus-key-manager.ts @@ -0,0 +1,29 @@ + +import {QueryList} from '@angular/core'; +import {ListKeyManager, CanDisable} from './list-key-manager'; + +/** + * This is the interface for focusable items (used by the FocusKeyManager). + * Each item must know how to focus itself and whether or not it is currently disabled. + */ +export interface Focusable extends CanDisable { + focus(): void; +} + + +export class FocusKeyManager extends ListKeyManager { + + constructor(items: QueryList) { + super(items); + } + + /** + * This method sets the active item to the item at the specified index. + * It also adds focuses the newly active item. + */ + setActiveItem(index: number): void { + super.setActiveItem(index); + this.activeItem.focus(); + } + +} diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts index 6a75c1be87ce..da36d2e8b99f 100644 --- a/src/lib/core/a11y/list-key-manager.spec.ts +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -1,12 +1,20 @@ import {QueryList} from '@angular/core'; -import {ListKeyManager} from './list-key-manager'; +import {FocusKeyManager} from './focus-key-manager'; import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes'; +import {ListKeyManager} from './list-key-manager'; +import {ActiveDescendantKeyManager} from './activedescendant-key-manager'; class FakeFocusable { disabled = false; focus() {} } +class FakeHighlightable { + disabled = false; + setActiveStyles() {} + setInactiveStyles() {} +} + class FakeQueryList extends QueryList { get length() { return this.items.length; } items: T[]; @@ -23,9 +31,8 @@ export class FakeEvent { } } -describe('ListKeyManager', () => { - let keyManager: ListKeyManager; - let itemList: FakeQueryList; +describe('Key managers', () => { + let itemList: FakeQueryList; let DOWN_ARROW_EVENT: KeyboardEvent; let UP_ARROW_EVENT: KeyboardEvent; let TAB_EVENT: KeyboardEvent; @@ -33,14 +40,7 @@ describe('ListKeyManager', () => { let END_EVENT: KeyboardEvent; beforeEach(() => { - itemList = new FakeQueryList(); - itemList.items = [ - new FakeFocusable(), - new FakeFocusable(), - new FakeFocusable() - ]; - - keyManager = new ListKeyManager(itemList); + itemList = new FakeQueryList(); DOWN_ARROW_EVENT = new FakeEvent(DOWN_ARROW) as KeyboardEvent; UP_ARROW_EVENT = new FakeEvent(UP_ARROW) as KeyboardEvent; @@ -48,285 +48,420 @@ describe('ListKeyManager', () => { HOME_EVENT = new FakeEvent(HOME) as KeyboardEvent; END_EVENT = new FakeEvent(END) as KeyboardEvent; - // first item is already focused - keyManager.focusFirstItem(); - - spyOn(itemList.items[0], 'focus'); - spyOn(itemList.items[1], 'focus'); - spyOn(itemList.items[2], 'focus'); }); - describe('key events', () => { - it('should focus subsequent items when down arrow is pressed', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).not.toHaveBeenCalled(); + describe('ListKeyManager', () => { + let keyManager: ListKeyManager; - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); - }); - - it('should focus previous items when up arrow is pressed', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); + beforeEach(() => { + itemList.items = [ + new FakeFocusable(), + new FakeFocusable(), + new FakeFocusable() + ]; - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + keyManager = new ListKeyManager(itemList); - keyManager.onKeydown(UP_ARROW_EVENT); + // first item is already focused + keyManager.setFirstItemActive(); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + spyOn(keyManager, 'setActiveItem').and.callThrough(); }); - it('should skip disabled items using arrow keys', () => { - itemList.items[1].disabled = true; + describe('Key events', () => { + + it('should set subsequent items as active when down arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + + expect(keyManager.activeItemIndex) + .toBe(1, 'Expected active item to be 1 after 1 down arrow event.'); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(1); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(2); + + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, 'Expected active item to be 2 after 2 down arrow events.'); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(2); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + }); + + it('should set previous items as active when up arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + + expect(keyManager.activeItemIndex) + .toBe(1, 'Expected active item to be 1 after 1 down arrow event.'); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(1); + + keyManager.onKeydown(UP_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(0, 'Expected active item to be 0 after 1 down and 1 up arrow event.'); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(0); + }); + + it('should skip disabled items using arrow keys', () => { + itemList.items[1].disabled = true; + + // down arrow should skip past disabled item from 0 to 2 + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, 'Expected active item to skip past disabled item on down arrow.'); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(1); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(2); + + // up arrow should skip past disabled item from 2 to 0 + keyManager.onKeydown(UP_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(0, 'Expected active item to skip past disabled item on up arrow.'); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(1); + }); + + it('should work normally when disabled property does not exist', () => { + itemList.items[0].disabled = undefined; + itemList.items[1].disabled = undefined; + itemList.items[2].disabled = undefined; + + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(1, 'Expected active item to be 1 after 1 down arrow when disabled not set.'); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(1); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(2); + + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, 'Expected active item to be 2 after 2 down arrows when disabled not set.'); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(0); + expect(keyManager.setActiveItem).toHaveBeenCalledWith(2); + }); + + it('should not move active item past either end of the list', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected last item of the list to be active.`); + + // this down arrow would move active item past the end of the list + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected active item to remain at the end of the list.`); + + keyManager.onKeydown(UP_ARROW_EVENT); + keyManager.onKeydown(UP_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + // this up arrow would move active item past the beginning of the list + keyManager.onKeydown(UP_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected active item to remain at the beginning of the list.`); + }); + + it('should not move active item to end when the last item is disabled', () => { + itemList.items[2].disabled = true; + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected second item of the list to be active.`); + + // this down arrow would set active item to the last item, which is disabled + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected the second item to remain active.`); + expect(keyManager.setActiveItem).not.toHaveBeenCalledWith(2); + }); + + it('should set the active item to the first item when HOME is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected last item of the list to be active.`); + + keyManager.onKeydown(HOME_EVENT); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected the HOME key to set the active item to the first item.`); + }); + + it('should set the active item to the last item when END is pressed', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + keyManager.onKeydown(END_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected the END key to set the active item to the last item.`); + }); + + it('should emit tabOut when the tab key is pressed', () => { + let tabOutEmitted = false; + keyManager.tabOut.first().subscribe(() => tabOutEmitted = true); + keyManager.onKeydown(TAB_EVENT); + + expect(tabOutEmitted).toBe(true); + }); + + it('should prevent the default keyboard action', () => { + expect(DOWN_ARROW_EVENT.defaultPrevented).toBe(false); + + keyManager.onKeydown(DOWN_ARROW_EVENT); + + expect(DOWN_ARROW_EVENT.defaultPrevented).toBe(true); + }); + + it('should not prevent the default keyboard action when pressing tab', () => { + expect(TAB_EVENT.defaultPrevented).toBe(false); + + keyManager.onKeydown(TAB_EVENT); + + expect(TAB_EVENT.defaultPrevented).toBe(false); + }); - // down arrow should skip past disabled item from 0 to 2 - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).not.toHaveBeenCalled(); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); - - // up arrow should skip past disabled item from 2 to 0 - keyManager.onKeydown(UP_ARROW_EVENT); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[1].focus).not.toHaveBeenCalled(); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); }); - it('should work normally when disabled property does not exist', () => { - itemList.items[0].disabled = undefined; - itemList.items[1].disabled = undefined; - itemList.items[2].disabled = undefined; + describe('programmatic focus', () => { + + it('should setActiveItem()', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + keyManager.setActiveItem(1); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected activeItemIndex to be updated when setActiveItem() was called.`); + }); + + it('should expose the active item correctly', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + + expect(keyManager.activeItemIndex).toBe(1, 'Expected active item to be the second option.'); + expect(keyManager.activeItem) + .toBe(itemList.items[1], 'Expected the active item to match the second option.'); + + + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex).toBe(2, 'Expected active item to be the third option.'); + expect(keyManager.activeItem) + .toBe(itemList.items[2], 'Expected the active item ID to match the third option.'); + }); + + it('should setFirstItemActive()', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected last item of the list to be active.`); + + keyManager.setFirstItemActive(); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected setFirstItemActive() to set the active item to the first item.`); + }); + + it('should set the active item to the second item if the first one is disabled', () => { + itemList.items[0].disabled = true; + + keyManager.setFirstItemActive(); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected the second item to be active if the first was disabled.`); + }); + + it('should setLastItemActive()', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + keyManager.setLastItemActive(); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected setLastItemActive() to set the active item to the last item.`); + }); + + it('should set the active item to the second to last item if the last is disabled', () => { + itemList.items[2].disabled = true; + + keyManager.setLastItemActive(); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected the second to last item to be active if the last was disabled.`); + }); + + it('should setNextItemActive()', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + keyManager.setNextItemActive(); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected setNextItemActive() to set the active item to the next item.`); + }); + + it('should set the active item to the next enabled item if next is disabled', () => { + itemList.items[1].disabled = true; + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); + + keyManager.setNextItemActive(); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected setNextItemActive() to only set enabled items as active.`); + }); + + it('should setPreviousItemActive()', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected second item of the list to be active.`); + + keyManager.setPreviousItemActive(); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected setPreviousItemActive() to set the active item to the previous.`); + }); + + it('should skip disabled items when setPreviousItemActive() is called', () => { + itemList.items[1].disabled = true; + keyManager.onKeydown(DOWN_ARROW_EVENT); + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex) + .toBe(2, `Expected third item of the list to be active.`); + + keyManager.setPreviousItemActive(); + expect(keyManager.activeItemIndex) + .toBe(0, `Expected setPreviousItemActive() to skip the disabled item.`); + }); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).not.toHaveBeenCalled(); - - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); }); - it('should not move focus past either end of the list', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focus to be on the last item of the list.`); - - // this down arrow would move focus past the end of the list - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focus to remain at the end of the list.`); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); + describe('wrap mode', () => { - keyManager.onKeydown(UP_ARROW_EVENT); - keyManager.onKeydown(UP_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); + it('should return itself to allow chaining', () => { + expect(keyManager.withWrap()) + .toEqual(keyManager, `Expected withWrap() to return an instance of ListKeyManager.`); + }); - // this up arrow would move focus past the beginning of the list - keyManager.onKeydown(UP_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to remain at the beginning of the list.`); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - }); + it('should wrap focus when arrow keying past items while in wrap mode', () => { + keyManager.withWrap(); + keyManager.onKeydown(DOWN_ARROW_EVENT); + keyManager.onKeydown(DOWN_ARROW_EVENT); - it('should not move focus when the last item is disabled', () => { - itemList.items[2].disabled = true; - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focus to be on the second item of the list.`); + expect(keyManager.activeItemIndex).toBe(2, 'Expected last item to be active.'); - // this down arrow would move focus the last item, which is disabled - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focus to remain on the second item.`); - expect(itemList.items[2].focus).not.toHaveBeenCalled(); - }); + // this down arrow moves down past the end of the list + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(keyManager.activeItemIndex).toBe(0, 'Expected active item to wrap to beginning.'); - it('should focus the first item when HOME is pressed', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focus to be on the last item of the list.`); + // this up arrow moves up past the beginning of the list + keyManager.onKeydown(UP_ARROW_EVENT); + expect(keyManager.activeItemIndex).toBe(2, 'Expected active item to wrap to end.'); + }); - keyManager.onKeydown(HOME_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected the HOME key to move the focus back to the first item.`); }); - it('should focus the last item when END is pressed', () => { - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); - - keyManager.onKeydown(END_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected the END key to move the focus to the last item in the list.`); - }); - - it('should emit tabOut when the tab key is pressed', () => { - let tabOutEmitted = false; - keyManager.tabOut.first().subscribe(() => tabOutEmitted = true); - keyManager.onKeydown(TAB_EVENT); - - expect(tabOutEmitted).toBe(true); - }); - - it('should prevent the default keyboard action', () => { - expect(DOWN_ARROW_EVENT.defaultPrevented).toBe(false); + }); - keyManager.onKeydown(DOWN_ARROW_EVENT); + describe('FocusKeyManager', () => { + let keyManager: FocusKeyManager; - expect(DOWN_ARROW_EVENT.defaultPrevented).toBe(true); - }); + beforeEach(() => { + itemList.items = [ + new FakeFocusable(), + new FakeFocusable(), + new FakeFocusable() + ]; - it('should not prevent the default keyboard action when pressing tab', () => { - expect(TAB_EVENT.defaultPrevented).toBe(false); + keyManager = new FocusKeyManager(itemList); - keyManager.onKeydown(TAB_EVENT); + // first item is already focused + keyManager.setFirstItemActive(); - expect(TAB_EVENT.defaultPrevented).toBe(false); + spyOn(itemList.items[0], 'focus'); + spyOn(itemList.items[1], 'focus'); + spyOn(itemList.items[2], 'focus'); }); - }); - - describe('programmatic focus', () => { + it('should focus subsequent items when down arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); - it('should setFocus()', () => { - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); - - keyManager.setFocus(1); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focusedItemIndex to be updated when setFocus() was called.`); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - }); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[2].focus).not.toHaveBeenCalled(); - it('should allow setting the focused item without calling focus', () => { - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); + keyManager.onKeydown(DOWN_ARROW_EVENT); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); + }); - keyManager.updateFocusedItemIndex(1); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focusedItemIndex to be updated after calling updateFocusedItemIndex().`); - expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1); - }); + it('should focus previous items when up arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); - it('should focus the first item when focusFirstItem() is called', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focus to be on the last item of the list.`); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - keyManager.focusFirstItem(); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focusFirstItem() to move the focus back to the first item.`); - }); + keyManager.onKeydown(UP_ARROW_EVENT); - it('should focus the second item if the first one is disabled', () => { - itemList.items[0].disabled = true; + expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + }); - keyManager.focusFirstItem(); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected the second item to be focused if the first was disabled.`); - }); + it('should allow setting the focused item without calling focus', () => { + expect(keyManager.activeItemIndex) + .toBe(0, `Expected first item of the list to be active.`); - it('should focus the last item when focusLastItem() is called', () => { - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); + keyManager.updateActiveItemIndex(1); + expect(keyManager.activeItemIndex) + .toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`); + expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1); + }); - keyManager.focusLastItem(); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focusLastItem() to move the focus to the last item in the list.`); - }); + }); - it('should focus the second to last item if the last one is disabled', () => { - itemList.items[2].disabled = true; + describe('ActiveDescendantKeyManager', () => { + let keyManager: ActiveDescendantKeyManager; - keyManager.focusLastItem(); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected the second to last item to be focused if the last was disabled.`); - }); + beforeEach(() => { + itemList.items = [ + new FakeHighlightable(), + new FakeHighlightable(), + new FakeHighlightable() + ]; - it('should focus the next item when focusNextItem() is called', () => { - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); + keyManager = new ActiveDescendantKeyManager(itemList); - keyManager.focusNextItem(); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focusNextItem() to move the focus to the next item.`); - }); + // first item is already focused + keyManager.setFirstItemActive(); - it('should focus the next enabled item if next is disabled', () => { - itemList.items[1].disabled = true; - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focus to be on the first item of the list.`); + spyOn(itemList.items[0], 'setActiveStyles'); + spyOn(itemList.items[1], 'setActiveStyles'); + spyOn(itemList.items[2], 'setActiveStyles'); - keyManager.focusNextItem(); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focusNextItem() to focus only enabled items.`); + spyOn(itemList.items[0], 'setInactiveStyles'); + spyOn(itemList.items[1], 'setInactiveStyles'); + spyOn(itemList.items[2], 'setInactiveStyles'); }); - it('should focus the previous item when focusPreviousItem() is called', () => { + it('should set subsequent items as active with the DOWN arrow', () => { keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(1, `Expected focus to be on the second item of the list.`); - keyManager.focusPreviousItem(); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focusPreviousItem() to move the focus to the last item.`); - }); + expect(itemList.items[1].setActiveStyles).toHaveBeenCalled(); + expect(itemList.items[2].setActiveStyles).not.toHaveBeenCalled(); - it('should skip disabled items when focusPreviousItem() is called', () => { - itemList.items[1].disabled = true; keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(keyManager.focusedItemIndex) - .toBe(2, `Expected focus to be on the third item of the list.`); - - keyManager.focusPreviousItem(); - expect(keyManager.focusedItemIndex) - .toBe(0, `Expected focusPreviousItem() to skip the disabled item.`); + expect(itemList.items[2].setActiveStyles).toHaveBeenCalled(); }); - }); + it('should set previous items as active with the UP arrow', () => { + keyManager.setLastItemActive(); - describe('wrap mode', () => { + keyManager.onKeydown(UP_ARROW_EVENT); + expect(itemList.items[1].setActiveStyles).toHaveBeenCalled(); + expect(itemList.items[0].setActiveStyles).not.toHaveBeenCalled(); - it('should return itself to allow chaining', () => { - expect(keyManager.withFocusWrap()) - .toEqual(keyManager, `Expected withFocusWrap() to return an instance of ListKeyManager`); + keyManager.onKeydown(UP_ARROW_EVENT); + expect(itemList.items[0].setActiveStyles).toHaveBeenCalled(); }); - it('should wrap focus when arrow keying past items while in wrap mode', () => { - keyManager.withFocusWrap(); - keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - - expect(itemList.items[0].focus).not.toHaveBeenCalled(); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); - - // this down arrow moves down past the end of the list + it('should set inactive styles on previously active items', () => { keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[0].setInactiveStyles).toHaveBeenCalled(); - // this up arrow moves up past the beginning of the list keyManager.onKeydown(UP_ARROW_EVENT); - expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - expect(itemList.items[2].focus).toHaveBeenCalledTimes(2); + expect(itemList.items[1].setInactiveStyles).toHaveBeenCalled(); }); }); + }); diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts index a7196f9483a4..01004d2239d4 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -4,63 +4,64 @@ import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; /** - * This is the interface for focusable items (used by the ListKeyManager). - * Each item must know how to focus itself and whether or not it is currently disabled. + * This interface is for items that can be disabled. The type passed into + * ListKeyManager must extend this interface. */ -export interface Focusable { - focus(): void; +export interface CanDisable { disabled?: boolean; } /** * This class manages keyboard events for selectable lists. If you pass it a query list - * of focusable items, it will focus the correct item when arrow events occur. + * of items, it will set the active item correctly when arrow events occur. */ -export class ListKeyManager { - private _focusedItemIndex: number; +export class ListKeyManager { + private _activeItemIndex: number; + private _activeItem: T; private _tabOut: Subject = new Subject(); private _wrap: boolean = false; - constructor(private _items: QueryList) {} + constructor(private _items: QueryList) { + } /** - * Turns on focus wrapping mode, which ensures that the focus will wrap to + * Turns on wrapping mode, which ensures that the active item will wrap to * the other end of list when there are no more items in the given direction. * * @returns The ListKeyManager that the method was called on. */ - withFocusWrap(): this { + withWrap(): this { this._wrap = true; return this; } /** - * Sets the focus of the list to the item at the index specified. + * Sets the active item to the item at the index specified. * - * @param index The index of the item to be focused. + * @param index The index of the item to be set as active. */ - setFocus(index: number): void { - this._focusedItemIndex = index; - this._items.toArray()[index].focus(); + setActiveItem(index: number): void { + this._activeItemIndex = index; + this._activeItem = this._items.toArray()[index]; } /** - * Sets the focus depending on the key event passed in. - * @param event Keyboard event to be used for determining which element to focus. + * Sets the active item depending on the key event passed in. + * @param event Keyboard event to be used for determining which element should be active. */ onKeydown(event: KeyboardEvent): void { switch (event.keyCode) { case DOWN_ARROW: - this.focusNextItem(); + this.setNextItemActive(); break; case UP_ARROW: - this.focusPreviousItem(); + this.setPreviousItemActive(); break; case HOME: - this.focusFirstItem(); + this.setFirstItemActive(); break; case END: - this.focusLastItem(); + this.setLastItemActive(); break; case TAB: // Note that we shouldn't prevent the default action on tab. @@ -73,37 +74,42 @@ export class ListKeyManager { event.preventDefault(); } - /** Focuses the first enabled item in the list. */ - focusFirstItem(): void { - this._setFocusByIndex(0, 1); + /** Returns the index of the currently active item. */ + get activeItemIndex(): number { + return this._activeItemIndex; + } + + /** Returns the currently active item. */ + get activeItem(): T { + return this._activeItem; } - /** Focuses the last enabled item in the list. */ - focusLastItem(): void { - this._setFocusByIndex(this._items.length - 1, -1); + /** Sets the active item to the first enabled item in the list. */ + setFirstItemActive(): void { + this._setActiveItemByIndex(0, 1); } - /** Focuses the next enabled item in the list. */ - focusNextItem(): void { - this._setFocusByDelta(1); + /** Sets the active item to the last enabled item in the list. */ + setLastItemActive(): void { + this._setActiveItemByIndex(this._items.length - 1, -1); } - /** Focuses a previous enabled item in the list. */ - focusPreviousItem(): void { - this._setFocusByDelta(-1); + /** Sets the active item to the next enabled item in the list. */ + setNextItemActive(): void { + this._setActiveItemByDelta(1); } - /** Returns the index of the currently focused item. */ - get focusedItemIndex(): number { - return this._focusedItemIndex; + /** Sets the active item to a previous enabled item in the list. */ + setPreviousItemActive(): void { + this._setActiveItemByDelta(-1); } /** - * Allows setting of the focusedItemIndex without focusing the item. - * @param index The new focusedItemIndex. + * Allows setting of the activeItemIndex without any other effects. + * @param index The new activeItemIndex. */ - updateFocusedItemIndex(index: number) { - this._focusedItemIndex = index; + updateActiveItemIndex(index: number) { + this._activeItemIndex = index; } /** @@ -115,56 +121,56 @@ export class ListKeyManager { } /** - * This method sets focus to the correct item, given a list of items and the delta - * between the currently focused item and the new item to be focused. It will calculate - * the proper focus differently depending on whether wrap mode is turned on. + * This method sets the active item, given a list of items and the delta between the + * currently active item and the new active item. It will calculate differently + * depending on whether wrap mode is turned on. */ - private _setFocusByDelta(delta: number, items = this._items.toArray()): void { - this._wrap ? this._setWrapModeFocus(delta, items) - : this._setDefaultModeFocus(delta, items); + private _setActiveItemByDelta(delta: number, items = this._items.toArray()): void { + this._wrap ? this._setActiveInWrapMode(delta, items) + : this._setActiveInDefaultMode(delta, items); } /** - * Sets the focus properly given "wrap" mode. In other words, it will continue to move + * Sets the active item properly given "wrap" mode. In other words, it will continue to move * down the list until it finds an item that is not disabled, and it will wrap if it * encounters either end of the list. */ - private _setWrapModeFocus(delta: number, items: Focusable[]): void { - // when focus would leave menu, wrap to beginning or end - this._focusedItemIndex = - (this._focusedItemIndex + delta + items.length) % items.length; - - // skip all disabled menu items recursively until an active one is reached - if (items[this._focusedItemIndex].disabled) { - this._setWrapModeFocus(delta, items); + private _setActiveInWrapMode(delta: number, items: T[]): void { + // when active item would leave menu, wrap to beginning or end + this._activeItemIndex = + (this._activeItemIndex + delta + items.length) % items.length; + + // skip all disabled menu items recursively until an enabled one is reached + if (items[this._activeItemIndex].disabled) { + this._setActiveInWrapMode(delta, items); } else { - items[this._focusedItemIndex].focus(); + this.setActiveItem(this._activeItemIndex); } } /** - * Sets the focus properly given the default mode. In other words, it will + * Sets the active item properly given the default mode. In other words, it will * continue to move down the list until it finds an item that is not disabled. If * it encounters either end of the list, it will stop and not wrap. */ - private _setDefaultModeFocus(delta: number, items: Focusable[]): void { - this._setFocusByIndex(this._focusedItemIndex + delta, delta, items); + private _setActiveInDefaultMode(delta: number, items: T[]): void { + this._setActiveItemByIndex(this._activeItemIndex + delta, delta, items); } /** - * Sets the focus to the first enabled item starting at the index specified. If the + * Sets the active item to the first enabled item starting at the index specified. If the * item is disabled, it will move in the fallbackDelta direction until it either * finds an enabled item or encounters the end of the list. */ - private _setFocusByIndex(index: number, fallbackDelta: number, - items = this._items.toArray()): void { + private _setActiveItemByIndex(index: number, fallbackDelta: number, + items = this._items.toArray()): void { if (!items[index]) { return; } while (items[index].disabled) { index += fallbackDelta; if (!items[index]) { return; } } - - this.setFocus(index); + this.setActiveItem(index); } } + diff --git a/src/lib/core/option/_option-theme.scss b/src/lib/core/option/_option-theme.scss index f639cdfb42f2..2b22c8ff4f58 100644 --- a/src/lib/core/option/_option-theme.scss +++ b/src/lib/core/option/_option-theme.scss @@ -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); } diff --git a/src/lib/core/option/option.ts b/src/lib/core/option/option.ts index 140e6daee33e..9d714fafc99d 100644 --- a/src/lib/core/option/option.ts +++ b/src/lib/core/option/option.ts @@ -20,6 +20,12 @@ import {MdRippleModule} from '../ripple/ripple'; */ let _uniqueIdCounter = 0; +/** Event object emitted by MdOption when selected. */ +export class MdOptionSelectEvent { + constructor(public source: MdOption, public isUserInput = false) {} +} + + /** * Single option inside of a `` element. */ @@ -30,6 +36,7 @@ let _uniqueIdCounter = 0; '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()', @@ -42,6 +49,7 @@ let _uniqueIdCounter = 0; }) export class MdOption { private _selected: boolean = false; + private _active: boolean = false; /** Whether the option is disabled. */ private _disabled: boolean = false; @@ -60,7 +68,7 @@ export class MdOption { set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } /** Event emitted when the option is selected. */ - @Output() onSelect = new EventEmitter(); + @Output() onSelect = new EventEmitter(); constructor(private _element: ElementRef, private _renderer: Renderer) {} @@ -69,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. @@ -81,7 +99,7 @@ export class MdOption { /** Selects the option. */ select(): void { this._selected = true; - this.onSelect.emit(); + this.onSelect.emit(new MdOptionSelectEvent(this, false)); } /** Deselects the option. */ @@ -94,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) { @@ -108,7 +144,7 @@ export class MdOption { _selectViaInteraction() { if (!this.disabled) { this._selected = true; - this.onSelect.emit(true); + this.onSelect.emit(new MdOptionSelectEvent(this, true)); } } diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index 07e9cded92df..765fb56030b0 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -17,7 +17,7 @@ import { import {MenuPositionX, MenuPositionY} from './menu-positions'; import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors'; import {MdMenuItem} from './menu-item'; -import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {MdMenuPanel} from './menu-panel'; import {Subscription} from 'rxjs/Subscription'; import {transformMenu, fadeInItems} from './menu-animations'; @@ -36,7 +36,7 @@ import {transformMenu, fadeInItems} from './menu-animations'; exportAs: 'mdMenu' }) export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { - private _keyManager: ListKeyManager; + private _keyManager: FocusKeyManager; /** Subscription to tab events on the menu panel */ private _tabSubscription: Subscription; @@ -61,7 +61,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { } ngAfterContentInit() { - this._keyManager = new ListKeyManager(this.items).withFocusWrap(); + this._keyManager = new FocusKeyManager(this.items).withWrap(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => { this._emitCloseEvent(); }); @@ -94,7 +94,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { * to focus the first item when the menu is opened by the ENTER key. */ focusFirstItem() { - this._keyManager.focusFirstItem(); + this._keyManager.setFirstItemActive(); } /** diff --git a/src/lib/menu/menu-item.ts b/src/lib/menu/menu-item.ts index 0425c0c6dc3f..9889fc74ebf4 100644 --- a/src/lib/menu/menu-item.ts +++ b/src/lib/menu/menu-item.ts @@ -1,5 +1,5 @@ import {Component, ElementRef, Input, HostBinding, Renderer} from '@angular/core'; -import {Focusable} from '../core/a11y/list-key-manager'; +import {Focusable} from '../core/a11y/focus-key-manager'; /** * This directive is intended to be used inside an md-menu tag. diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 8603a2719b4e..94d2cc8493e4 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -128,7 +128,7 @@ describe('MdSelect', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(0); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0); }); })); @@ -203,7 +203,7 @@ describe('MdSelect', () => { // must wait for animation to finish fixture.whenStable().then(() => { fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(1); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(1); }); }); })); diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index cb7d2e7a3555..75927c326034 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -13,9 +13,9 @@ import { ViewEncapsulation, ViewChild, } from '@angular/core'; -import {MdOption} from '../core/option/option'; +import {MdOption, MdOptionSelectEvent} from '../core/option/option'; import {ENTER, SPACE} from '../core/keyboard/keycodes'; -import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {FocusKeyManager} from '../core/a11y/focus-key-manager'; import {Dir} from '../core/rtl/dir'; import {Subscription} from 'rxjs/Subscription'; import {transformPlaceholder, transformPanel, fadeInContent} from './select-animations'; @@ -138,7 +138,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr _selectedValueWidth: number; /** Manages keyboard events for options in the panel. */ - _keyManager: ListKeyManager; + _keyManager: FocusKeyManager; /** View -> model callback called when value changes */ _onChange = (value: any) => {}; @@ -425,7 +425,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Sets up a key manager to listen to keyboard events on the overlay panel. */ private _initKeyManager() { - this._keyManager = new ListKeyManager(this.options); + this._keyManager = new FocusKeyManager(this.options); this._tabSubscription = this._keyManager.tabOut.subscribe(() => { this.close(); }); @@ -441,8 +441,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr /** Listens to selection events on each option. */ private _listenToOptions(): void { this.options.forEach((option: MdOption) => { - const sub = option.onSelect.subscribe((isUserInput: boolean) => { - if (isUserInput && this._selected !== option) { + const sub = option.onSelect.subscribe((event: MdOptionSelectEvent) => { + if (event.isUserInput && this._selected !== option) { this._emitChangeEvent(option); } this._onSelect(option); @@ -502,9 +502,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr */ private _focusCorrectOption(): void { if (this.selected) { - this._keyManager.setFocus(this._getOptionIndex(this.selected)); + this._keyManager.setActiveItem(this._getOptionIndex(this.selected)); } else { - this._keyManager.focusFirstItem(); + this._keyManager.setFirstItemActive(); } } diff --git a/tools/gulp/tasks/components.ts b/tools/gulp/tasks/components.ts index 64102458d0d1..44c41d743260 100644 --- a/tools/gulp/tasks/components.ts +++ b/tools/gulp/tasks/components.ts @@ -85,6 +85,8 @@ task(':build:components:rollup', () => { 'rxjs/add/operator/finally': 'Rx.Observable.prototype', 'rxjs/add/operator/catch': 'Rx.Observable.prototype', 'rxjs/add/operator/first': 'Rx.Observable.prototype', + 'rxjs/add/operator/startWith': 'Rx.Observable.prototype', + 'rxjs/add/operator/switchMap': 'Rx.Observable.prototype', 'rxjs/Observable': 'Rx' };