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'
};