diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts index a8be2ae7ec94..918f9c049bf5 100644 --- a/src/lib/core/a11y/list-key-manager.spec.ts +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -1,125 +1,293 @@ import {QueryList} from '@angular/core'; -import {ListKeyManager, MdFocusable} from './list-key-manager'; -import {DOWN_ARROW, UP_ARROW, TAB} from '../keyboard/keycodes'; +import {ListKeyManager} from './list-key-manager'; +import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes'; class FakeFocusable { disabled = false; focus() {} } +class FakeQueryList extends QueryList { + get length() { return this.items.length; } + items: T[]; + toArray() { + return this.items; + } +} + const DOWN_ARROW_EVENT = { keyCode: DOWN_ARROW } as KeyboardEvent; const UP_ARROW_EVENT = { keyCode: UP_ARROW } as KeyboardEvent; const TAB_EVENT = { keyCode: TAB } as KeyboardEvent; +const HOME_EVENT = { keyCode: HOME } as KeyboardEvent; +const END_EVENT = { keyCode: END } as KeyboardEvent; describe('ListKeyManager', () => { let keyManager: ListKeyManager; - let itemList: QueryList; - let items: MdFocusable[]; + let itemList: FakeQueryList; beforeEach(() => { - itemList = new QueryList(); - items = [ + itemList = new FakeQueryList(); + itemList.items = [ new FakeFocusable(), new FakeFocusable(), new FakeFocusable() ]; - itemList.toArray = () => items; - keyManager = new ListKeyManager(itemList); // first item is already focused - keyManager.focusedItemIndex = 0; + keyManager.focusFirstItem(); - spyOn(items[0], 'focus'); - spyOn(items[1], 'focus'); - spyOn(items[2], 'focus'); + spyOn(itemList.items[0], 'focus'); + spyOn(itemList.items[1], 'focus'); + spyOn(itemList.items[2], 'focus'); }); - it('should focus subsequent items when down arrow is pressed', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); + describe('key events', () => { + it('should focus subsequent items when down arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).not.toHaveBeenCalled(); + 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(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).toHaveBeenCalledTimes(1); - }); + 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); + it('should focus previous items when up arrow is pressed', () => { + keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[0].focus).not.toHaveBeenCalled(); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); - keyManager.onKeydown(UP_ARROW_EVENT); + keyManager.onKeydown(UP_ARROW_EVENT); - expect(items[0].focus).toHaveBeenCalledTimes(1); - expect(items[1].focus).toHaveBeenCalledTimes(1); - }); + expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); + expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); + }); - it('should skip disabled items using arrow keys', () => { - items[1].disabled = true; + 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(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).not.toHaveBeenCalled(); - expect(items[2].focus).toHaveBeenCalledTimes(1); + // 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(items[0].focus).toHaveBeenCalledTimes(1); - expect(items[1].focus).not.toHaveBeenCalled(); - expect(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; + + 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); - it('should work normally when disabled property does not exist', () => { - items[0].disabled = undefined; - items[1].disabled = undefined; - items[2].disabled = undefined; + 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.`); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).not.toHaveBeenCalled(); + // 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 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.`); + + // 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(); + }); + + 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.`); + + 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); + }); - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).toHaveBeenCalledTimes(1); }); - it('should wrap back to menu when arrow keying past items', () => { - keyManager.onKeydown(DOWN_ARROW_EVENT); - keyManager.onKeydown(DOWN_ARROW_EVENT); - - expect(items[0].focus).not.toHaveBeenCalled(); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).toHaveBeenCalledTimes(1); - - // this down arrow moves down past the end of the list - keyManager.onKeydown(DOWN_ARROW_EVENT); - expect(items[0].focus).toHaveBeenCalledTimes(1); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).toHaveBeenCalledTimes(1); - - // this up arrow moves up past the beginning of the list - keyManager.onKeydown(UP_ARROW_EVENT); - expect(items[0].focus).toHaveBeenCalledTimes(1); - expect(items[1].focus).toHaveBeenCalledTimes(1); - expect(items[2].focus).toHaveBeenCalledTimes(2); + describe('programmatic focus', () => { + + 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); + }); + + 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.`); + + keyManager.focusFirstItem(); + expect(keyManager.focusedItemIndex) + .toBe(0, `Expected focusFirstItem() to move the focus back to the first item.`); + }); + + it('should focus the second item if the first one is disabled', () => { + itemList.items[0].disabled = true; + + keyManager.focusFirstItem(); + expect(keyManager.focusedItemIndex) + .toBe(1, `Expected the second item to be focused if the first was disabled.`); + }); + + 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.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; + + keyManager.focusLastItem(); + expect(keyManager.focusedItemIndex) + .toBe(1, `Expected the second to last item to be focused if the last was disabled.`); + }); + + 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.focusNextItem(); + expect(keyManager.focusedItemIndex) + .toBe(1, `Expected focusNextItem() to move the focus to the next item.`); + }); + + 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.`); + + keyManager.focusNextItem(); + expect(keyManager.focusedItemIndex) + .toBe(2, `Expected focusNextItem() to focus only enabled items.`); + }); + + it('should focus the previous item when focusPreviousItem() is called', () => { + 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.`); + }); + + 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.`); + }); + }); - it('should emit tabOut when the tab key is pressed', () => { - let tabOutEmitted = false; - keyManager.tabOut.first().subscribe(() => tabOutEmitted = true); - keyManager.onKeydown(TAB_EVENT); + describe('wrap mode', () => { + + it('should return itself to allow chaining', () => { + expect(keyManager.withFocusWrap()) + .toEqual(keyManager, `Expected withFocusWrap() to return an instance of ListKeyManager`); + }); + + 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 + 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); + + // 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(tabOutEmitted).toBe(true); }); }); diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts index 98bc878c06f5..8e447ff0c327 100644 --- a/src/lib/core/a11y/list-key-manager.ts +++ b/src/lib/core/a11y/list-key-manager.ts @@ -1,5 +1,5 @@ import {QueryList} from '@angular/core'; -import {UP_ARROW, DOWN_ARROW, TAB} from '../core'; +import {UP_ARROW, DOWN_ARROW, TAB, HOME, END} from '../core'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; @@ -19,65 +19,130 @@ export interface MdFocusable { export class ListKeyManager { private _focusedItemIndex: number; private _tabOut: Subject = new Subject(); + private _wrap: boolean = false; constructor(private _items: QueryList) {} /** - * Observable that emits any time the TAB key is pressed, so components can react - * when focus is shifted off of the list. + * Turns on focus wrapping mode, which ensures that the focus will wrap to + * the other end of list when there are no more items in the given direction. */ - get tabOut(): Observable { - return this._tabOut.asObservable(); - } - - get focusedItemIndex(): number { - return this._focusedItemIndex; + withFocusWrap(): this { + this._wrap = true; + return this; } - set focusedItemIndex(value: number) { - this._focusedItemIndex = value; + /** Sets the focus of the list to the item at the index specified. */ + setFocus(index: number): void { + this._focusedItemIndex = index; + this._items.toArray()[index].focus(); } + /** Sets the focus properly depending on the key event passed in. */ onKeydown(event: KeyboardEvent): void { - if (event.keyCode === DOWN_ARROW) { - this._focusNextItem(); - } else if (event.keyCode === UP_ARROW) { - this._focusPreviousItem(); - } else if (event.keyCode === TAB) { - this._tabOut.next(null); + switch (event.keyCode) { + case DOWN_ARROW: + this.focusNextItem(); + break; + case UP_ARROW: + this.focusPreviousItem(); + break; + case HOME: + this.focusFirstItem(); + break; + case END: + this.focusLastItem(); + break; + case TAB: + this._tabOut.next(null); + break; } } - private _focusNextItem(): void { - const items = this._items.toArray(); - this._updateFocusedItemIndex(1, items); - items[this._focusedItemIndex].focus(); + /** Focuses the first enabled item in the list. */ + focusFirstItem(): void { + this._setFocusByIndex(0, 1); + } + + /** Focuses the last enabled item in the list. */ + focusLastItem(): void { + this._setFocusByIndex(this._items.length - 1, -1); } - private _focusPreviousItem(): void { - const items = this._items.toArray(); - this._updateFocusedItemIndex(-1, items); - items[this._focusedItemIndex].focus(); + /** Focuses the next enabled item in the list. */ + focusNextItem(): void { + this._setFocusByDelta(1); + } + + /** Focuses a previous enabled item in the list. */ + focusPreviousItem(): void { + this._setFocusByDelta(-1); + } + + /** Returns the index of the currently focused item. */ + get focusedItemIndex(): number { + return this._focusedItemIndex; + } + + /** + * Observable that emits any time the TAB key is pressed, so components can react + * when focus is shifted off of the list. + */ + get tabOut(): Observable { + return this._tabOut.asObservable(); } /** * 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 - * 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. - * - * @param delta the desired change in focus index + * 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. */ - private _updateFocusedItemIndex(delta: number, items: MdFocusable[]) { + private _setFocusByDelta(delta: number, items = this._items.toArray()): void { + this._wrap ? this._setWrapModeFocus(delta, items) + : this._setDefaultModeFocus(delta, items); + } + + /** + * Sets the focus 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: MdFocusable[]): 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 or the menu closes for overreaching bounds - while (items[this._focusedItemIndex].disabled) { - this._updateFocusedItemIndex(delta, items); + // skip all disabled menu items recursively until an active one is reached + if (items[this._focusedItemIndex].disabled) { + this._setWrapModeFocus(delta, items); + } else { + items[this._focusedItemIndex].focus(); } } + /** + * Sets the focus 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: MdFocusable[]): void { + this._setFocusByIndex(this._focusedItemIndex + delta, delta, items); + } + + /** + * Sets the focus 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 { + if (!items[index]) { return; } + while (items[index].disabled) { + index += fallbackDelta; + if (!items[index]) { return; } + } + + this.setFocus(index); + } + } diff --git a/src/lib/core/keyboard/keycodes.ts b/src/lib/core/keyboard/keycodes.ts index f3626d258539..e9d5ebbf4dc5 100644 --- a/src/lib/core/keyboard/keycodes.ts +++ b/src/lib/core/keyboard/keycodes.ts @@ -12,3 +12,6 @@ export const LEFT_ARROW = 37; export const ENTER = 13; export const SPACE = 32; export const TAB = 9; + +export const HOME = 36; +export const END = 35; diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index d532f3a7b981..05239f00989b 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -59,7 +59,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { // TODO: internal ngAfterContentInit() { - this._keyManager = new ListKeyManager(this.items); + this._keyManager = new ListKeyManager(this.items).withFocusWrap(); this._tabSubscription = this._keyManager.tabOut.subscribe(() => { this._emitCloseEvent(); }); @@ -94,9 +94,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy { * TODO: internal */ focusFirstItem() { - // The menu always opens with the first item focused. - this.items.first.focus(); - this._keyManager.focusedItemIndex = 0; + this._keyManager.focusFirstItem(); } /** diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 09eefc69f60a..835499f8207c 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -282,11 +282,9 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr */ private _focusCorrectOption(): void { if (this.selected) { - this._keyManager.focusedItemIndex = this._getOptionIndex(this.selected); - this.selected.focus(); + this._keyManager.setFocus(this._getOptionIndex(this.selected)); } else { - this._keyManager.focusedItemIndex = 0; - this.options.first.focus(); + this._keyManager.focusFirstItem(); } }