feat(list-key-manager): active descendant support (#2606)

This commit is contained in:
Kara
2017-01-18 21:33:06 -08:00
committed by mmalerba
parent 899f190da7
commit e2ad3a0159
11 changed files with 542 additions and 337 deletions
+10 -10
View File
@@ -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<MdChip>;
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', () => {
+11 -11
View File
@@ -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<MdChip>;
@@ -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);
}
}
+1 -1
View File
@@ -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 {
@@ -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<Highlightable> {
constructor(items: QueryList<Highlightable>) {
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();
}
}
}
+29
View File
@@ -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<Focusable> {
constructor(items: QueryList<Focusable>) {
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();
}
}
+375 -240
View File
@@ -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<T> extends QueryList<T> {
get length() { return this.items.length; }
items: T[];
@@ -23,9 +31,8 @@ export class FakeEvent {
}
}
describe('ListKeyManager', () => {
let keyManager: ListKeyManager;
let itemList: FakeQueryList<FakeFocusable>;
describe('Key managers', () => {
let itemList: FakeQueryList<any>;
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<FakeFocusable>();
itemList.items = [
new FakeFocusable(),
new FakeFocusable(),
new FakeFocusable()
];
keyManager = new ListKeyManager(itemList);
itemList = new FakeQueryList<any>();
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<FakeFocusable>;
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);
beforeEach(() => {
itemList.items = [
new FakeFocusable(),
new FakeFocusable(),
new FakeFocusable()
];
keyManager = new ListKeyManager<FakeFocusable>(itemList);
// first item is already focused
keyManager.setFirstItemActive();
spyOn(keyManager, 'setActiveItem').and.callThrough();
});
it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
describe('Key events', () => {
expect(itemList.items[0].focus).not.toHaveBeenCalled();
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
it('should set subsequent items as active when down arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
keyManager.onKeydown(UP_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);
});
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
});
it('should skip disabled items using arrow keys', () => {
itemList.items[1].disabled = true;
describe('programmatic focus', () => {
// 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);
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.`);
});
// 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('wrap mode', () => {
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();
it('should return itself to allow chaining', () => {
expect(keyManager.withWrap())
.toEqual(keyManager, `Expected withWrap() to return an instance of 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 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 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.`);
expect(keyManager.activeItemIndex).toBe(2, 'Expected last item to be active.');
// 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);
// 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.');
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.`);
// 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.');
});
// 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);
});
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);
});
});
describe('programmatic focus', () => {
describe('FocusKeyManager', () => {
let keyManager: FocusKeyManager;
it('should setFocus()', () => {
expect(keyManager.focusedItemIndex)
.toBe(0, `Expected focus to be on the first item of the list.`);
beforeEach(() => {
itemList.items = [
new FakeFocusable(),
new FakeFocusable(),
new FakeFocusable()
];
keyManager.setFocus(1);
expect(keyManager.focusedItemIndex)
.toBe(1, `Expected focusedItemIndex to be updated when setFocus() was called.`);
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
keyManager = new FocusKeyManager(itemList);
// first item is already focused
keyManager.setFirstItemActive();
spyOn(itemList.items[0], 'focus');
spyOn(itemList.items[1], 'focus');
spyOn(itemList.items[2], 'focus');
});
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.`);
it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
keyManager.updateFocusedItemIndex(1);
expect(keyManager.focusedItemIndex)
.toBe(1, `Expected focusedItemIndex to be updated after calling updateFocusedItemIndex().`);
expect(itemList.items[1].focus).not.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 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.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.focusFirstItem();
expect(keyManager.focusedItemIndex)
.toBe(0, `Expected focusFirstItem() to move the focus back to the first item.`);
});
it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
it('should focus the second item if the first one is disabled', () => {
itemList.items[0].disabled = true;
expect(itemList.items[0].focus).not.toHaveBeenCalled();
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.`);
});
keyManager.onKeydown(UP_ARROW_EVENT);
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.`);
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1);
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1);
});
keyManager.focusLastItem();
expect(keyManager.focusedItemIndex)
.toBe(2, `Expected focusLastItem() to move the focus to the last item in the list.`);
});
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 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.`);
});
keyManager.updateActiveItemIndex(1);
expect(keyManager.activeItemIndex)
.toBe(1, `Expected activeItemIndex to update after calling updateActiveItemIndex().`);
expect(itemList.items[1].focus).not.toHaveBeenCalledTimes(1);
});
});
describe('wrap mode', () => {
describe('ActiveDescendantKeyManager', () => {
let keyManager: ActiveDescendantKeyManager;
it('should return itself to allow chaining', () => {
expect(keyManager.withFocusWrap())
.toEqual(keyManager, `Expected withFocusWrap() to return an instance of ListKeyManager`);
beforeEach(() => {
itemList.items = [
new FakeHighlightable(),
new FakeHighlightable(),
new FakeHighlightable()
];
keyManager = new ActiveDescendantKeyManager(itemList);
// first item is already focused
keyManager.setFirstItemActive();
spyOn(itemList.items[0], 'setActiveStyles');
spyOn(itemList.items[1], 'setActiveStyles');
spyOn(itemList.items[2], 'setActiveStyles');
spyOn(itemList.items[0], 'setInactiveStyles');
spyOn(itemList.items[1], 'setInactiveStyles');
spyOn(itemList.items[2], 'setInactiveStyles');
});
it('should wrap focus when arrow keying past items while in wrap mode', () => {
keyManager.withFocusWrap();
keyManager.onKeydown(DOWN_ARROW_EVENT);
it('should set subsequent items as active with the DOWN arrow', () => {
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);
expect(itemList.items[1].setActiveStyles).toHaveBeenCalled();
expect(itemList.items[2].setActiveStyles).not.toHaveBeenCalled();
// 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);
expect(itemList.items[2].setActiveStyles).toHaveBeenCalled();
});
it('should set previous items as active with the UP arrow', () => {
keyManager.setLastItemActive();
// 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].setActiveStyles).toHaveBeenCalled();
expect(itemList.items[0].setActiveStyles).not.toHaveBeenCalled();
keyManager.onKeydown(UP_ARROW_EVENT);
expect(itemList.items[0].setActiveStyles).toHaveBeenCalled();
});
it('should set inactive styles on previously active items', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(itemList.items[0].setInactiveStyles).toHaveBeenCalled();
keyManager.onKeydown(UP_ARROW_EVENT);
expect(itemList.items[1].setInactiveStyles).toHaveBeenCalled();
});
});
});
+69 -63
View File
@@ -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<T extends CanDisable> {
private _activeItemIndex: number;
private _activeItem: T;
private _tabOut: Subject<any> = new Subject();
private _wrap: boolean = false;
constructor(private _items: QueryList<Focusable>) {}
constructor(private _items: QueryList<T>) {
}
/**
* 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;
}
/** Focuses the last enabled item in the list. */
focusLastItem(): void {
this._setFocusByIndex(this._items.length - 1, -1);
/** Returns the currently active item. */
get activeItem(): T {
return this._activeItem;
}
/** Focuses the next enabled item in the list. */
focusNextItem(): void {
this._setFocusByDelta(1);
/** Sets the active item to the first enabled item in the list. */
setFirstItemActive(): void {
this._setActiveItemByIndex(0, 1);
}
/** Focuses a previous enabled item in the list. */
focusPreviousItem(): 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);
}
/** Returns the index of the currently focused item. */
get focusedItemIndex(): number {
return this._focusedItemIndex;
/** Sets the active item to the next enabled item in the list. */
setNextItemActive(): void {
this._setActiveItemByDelta(1);
}
/** 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;
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 active one is reached
if (items[this._focusedItemIndex].disabled) {
this._setWrapModeFocus(delta, items);
// 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);
}
}
+4 -4
View File
@@ -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();
}
/**
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -135,7 +135,7 @@ describe('MdSelect', () => {
fixture.detectChanges();
fixture.whenStable().then(() => {
expect(fixture.componentInstance.select._keyManager.focusedItemIndex).toEqual(0);
expect(fixture.componentInstance.select._keyManager.activeItemIndex).toEqual(0);
});
}));
@@ -232,7 +232,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);
});
});
}));
+5 -5
View File
@@ -15,7 +15,7 @@ import {
} from '@angular/core';
import {MdOption} 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) => {};
@@ -439,7 +439,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();
});
@@ -516,9 +516,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();
}
}