Skip to content

Commit

Permalink
feat(forms): add keyboard events and accessibility
Browse files Browse the repository at this point in the history
  • Loading branch information
kara committed Jul 30, 2016
1 parent 8da751c commit 7507eeb
Show file tree
Hide file tree
Showing 13 changed files with 288 additions and 47 deletions.
13 changes: 13 additions & 0 deletions e2e/components/menu/menu-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export class MenuPage {

menu() { return element(by.css('.md-menu')); }

start() { return element(by.id('start')); }

trigger() { return element(by.id('trigger')); }

triggerTwo() { return element(by.id('trigger-two')); }
Expand All @@ -32,6 +34,17 @@ export class MenuPage {

combinedMenu() { return element(by.css('.md-menu.combined')); }

// TODO(kara): move to common testing utility
pressKey(key: any): void {
browser.actions().sendKeys(key).perform();
}

// TODO(kara): move to common testing utility
expectFocusOn(el: ElementFinder): void {
expect(browser.driver.switchTo().activeElement().getInnerHtml())
.toBe(el.getInnerHtml());
}

expectMenuPresent(expected: boolean) {
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
expect(isPresent).toBe(expected);
Expand Down
137 changes: 134 additions & 3 deletions e2e/components/menu/menu.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('menu', () => {
page.trigger().click();

page.expectMenuPresent(true);
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
});

it('should close menu when area outside menu is clicked', () => {
Expand Down Expand Up @@ -45,14 +45,14 @@ describe('menu', () => {

it('should support multiple triggers opening the same menu', () => {
page.triggerTwo().click();
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
page.expectMenuAlignedWith(page.menu(), 'trigger-two');

page.body().click();
page.expectMenuPresent(false);

page.trigger().click();
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
page.expectMenuAlignedWith(page.menu(), 'trigger');

page.body().click();
Expand All @@ -66,6 +66,137 @@ describe('menu', () => {
});
});

describe('keyboard events', () => {
beforeEach(() => {
// click start button to avoid tabbing past navigation
page.start().click();
page.pressKey(protractor.Key.TAB);
});

it('should auto-focus the first item when opened with keyboard', () => {
page.pressKey(protractor.Key.ENTER);
page.expectFocusOn(page.items(0));
});

it('should not focus the first item when opened with mouse', () => {
page.trigger().click();
page.expectFocusOn(page.trigger());
});

it('should focus subsequent items when down arrow is pressed', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.DOWN);
page.expectFocusOn(page.items(1));
});

it('should focus previous items when up arrow is pressed', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.UP);
page.expectFocusOn(page.items(0));
});

it('should focus subsequent items when tab is pressed', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
page.expectFocusOn(page.items(1));
});

it('should focus previous items when shift-tab is pressed', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
// need a protractor "chord" to hit shift-tab simultaneously
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.expectFocusOn(page.items(0));
});

it('should handle a mix of tabs and arrow presses', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.UP);
page.expectFocusOn(page.items(0));

page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.expectFocusOn(page.items(0));
});

it('should skip disabled items using arrow keys', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.DOWN);
page.expectFocusOn(page.items(3));

page.pressKey(protractor.Key.UP);
page.expectFocusOn(page.items(1));
});

it('should skip disabled items using tabs', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.TAB);
page.expectFocusOn(page.items(3));

page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.expectFocusOn(page.items(1));
});

it('should close the menu when tabbing past items', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.TAB);
page.expectMenuPresent(false);

page.start().click();
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.expectMenuPresent(false);
});

it('should close the menu when arrow keying past items', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.DOWN);
page.expectMenuPresent(false);

page.start().click();
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.UP);
page.expectMenuPresent(false);
});

it('should focus before and after trigger when tabbing past items', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.TAB);
page.pressKey(protractor.Key.TAB);
page.expectFocusOn(page.triggerTwo());

// navigate back to trigger
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.pressKey(protractor.Key.ENTER);

page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
page.expectFocusOn(page.start());
});

it('should focus on trigger when arrow keying past items', () => {
page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.DOWN);
page.pressKey(protractor.Key.DOWN);
page.expectFocusOn(page.trigger());

page.pressKey(protractor.Key.ENTER);
page.pressKey(protractor.Key.UP);
page.expectFocusOn(page.trigger());
});
});

describe('position - ', () => {

it('should default menu alignment to "after below" when not set', () => {
Expand Down
8 changes: 6 additions & 2 deletions src/components/menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ Output:
### Accessibility
The menu adds `role="menu"` to the main menu element and `role="menuitem"` to each menu item. It
also adds `aria-hasPopup="true"` to the trigger element.
also adds `aria-hasPopup="true"` to the trigger element.
#### Keyboard events:
- DOWN_ARROW or TAB: Focus next menu item
- UP_ARROW or SHIFT_TAB: Focus previous menu item
- ENTER: Select focused item
### Menu attributes
Expand Down Expand Up @@ -160,7 +165,6 @@ also adds `aria-hasPopup="true"` to the trigger element.
### TODO
- Keyboard events: up arrow, down arrow, enter
- `prevent-close` option, to turn off automatic menu close when clicking outside the menu
- Custom offset support
Expand Down
65 changes: 60 additions & 5 deletions src/components/menu/menu-directive.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
// TODO(kara): keyboard events for menu navigation
// TODO(kara): prevent-close functionality

import {
Attribute,
Component,
ContentChildren,
EventEmitter,
Input,
Output,
QueryList,
TemplateRef,
ViewChild,
ViewEncapsulation
} from '@angular/core';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
import {MdMenuItem} from './menu-item';
import {UP_ARROW, DOWN_ARROW, TAB} from '@angular2-material/core/keyboard/keycodes';

@Component({
moduleId: module.id,
Expand All @@ -25,6 +28,7 @@ import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
})
export class MdMenu {
private _showClickCatcher: boolean = false;
private _focusedItem: number = 0;

// config object to be passed into the menu's ngClass
private _classList: Object;
Expand All @@ -33,6 +37,7 @@ export class MdMenu {
positionY: MenuPositionY = 'below';

@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@ContentChildren(MdMenuItem) items: QueryList<MdMenuItem>;

constructor(@Attribute('x-position') posX: MenuPositionX,
@Attribute('y-position') posY: MenuPositionY) {
Expand Down Expand Up @@ -65,6 +70,60 @@ export class MdMenu {
this._showClickCatcher = bool;
}

/**
* This method is used by the menu trigger to focus the first item when the menu is opened
* by the ENTER key.
* TODO: internal
*/
_focusFirstItem() { this.items.first.focus(); }

// TODO(kara): update this when (keydown.downArrow) testability is fixed
private _checkKey(event: KeyboardEvent): void {
if (event.keyCode === DOWN_ARROW) {
this._focusNextItem();
} else if (event.keyCode === UP_ARROW) {
this._focusPreviousItem();
} else if (event.keyCode === TAB) {
this._checkTabDirection(event.shiftKey);
}
}

private _checkTabDirection(shiftPressed: boolean): void {
shiftPressed ? this._updateFocusedItem(-1) : this._updateFocusedItem(1);
}

private _emitCloseEvent(): void {
this._focusedItem = 0;
this.close.emit(null);
}

private _checkMenuBounds(): void {
if (this._focusedItem === this.items.length || this._focusedItem < 0) {
this._emitCloseEvent();
}
}

private _focusNextItem(): void {
this._updateFocusedItem(1);
this.items.toArray()[this._focusedItem].focus();
}

private _focusPreviousItem(): void {
this._updateFocusedItem(-1);
this.items.toArray()[this._focusedItem].focus();
}

private _updateFocusedItem(delta: number) {
this._focusedItem += delta;
this._checkMenuBounds();

// skip all disabled menu items recursively until an active one
// is reached or the menu closes for overreaching bounds
while (this.items.toArray()[this._focusedItem].disabled) {
this._updateFocusedItem(delta);
}
}

private _setPositionX(pos: MenuPositionX): void {
if ( pos !== 'before' && pos !== 'after') {
throw new MdMenuInvalidPositionX();
Expand All @@ -78,8 +137,4 @@ export class MdMenu {
}
this.positionY = pos;
}

private _emitCloseEvent(): void {
this.close.emit(null);
}
}
32 changes: 15 additions & 17 deletions src/components/menu/menu-item.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,27 @@
import {Directive, Input, HostBinding} from '@angular/core';
import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core';

/**
* This directive is intended to be used inside an md-menu tag.
* It exists mostly to set the role attribute.
*/
@Directive({
selector: 'button[md-menu-item]',
host: {'role': 'menuitem'}
})
export class MdMenuItem {}

/**
* This directive is intended to be used inside an md-menu tag.
* It sets the role attribute and adds support for the disabled property to anchors.
*/
@Directive({
selector: 'a[md-menu-item]',
selector: '[md-menu-item]',
host: {
'role': 'menuitem',
'(click)': 'checkDisabled($event)'
}
'(click)': '_checkDisabled($event)'
},
exportAs: 'mdMenuItem'
})
export class MdMenuAnchor {
export class MdMenuItem {
_disabled: boolean;

constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}

focus(): void {
this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus');
}

// this is necessary to support anchors
@HostBinding('attr.disabled')
@Input()
get disabled(): boolean {
Expand All @@ -38,16 +36,16 @@ export class MdMenuAnchor {
get isAriaDisabled(): string {
return String(this.disabled);
}

@HostBinding('tabIndex')
get tabIndex(): number {
return this.disabled ? -1 : 0;
}

checkDisabled(event: Event) {
private _checkDisabled(event: Event) {
if (this.disabled) {
event.preventDefault();
event.stopPropagation();
}
}
}

Loading

0 comments on commit 7507eeb

Please sign in to comment.