-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(menu): add keyboard events and accessibility #937
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||
|
@@ -25,6 +28,7 @@ import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors'; | |
}) | ||
export class MdMenu { | ||
_showClickCatcher: boolean = false; | ||
private _focusedItemIndex: number = 0; | ||
|
||
// config object to be passed into the menu's ngClass | ||
_classList: Object; | ||
|
@@ -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) { | ||
|
@@ -65,6 +70,72 @@ export class MdMenu { | |
this._showClickCatcher = bool; | ||
} | ||
|
||
/** | ||
* Focus the first item in the menu. 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(); } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd make the function description something like /**
* Focus the first item in the menu. This method is intended to be used by the menu trigger
* to focus the first item when the menu is opened by the ENTER key.
* TODO: internal
*/ |
||
|
||
// TODO(kara): update this when (keydown.downArrow) testability is fixed | ||
// TODO: internal | ||
_handleKeydown(event: KeyboardEvent): void { | ||
if (event.keyCode === DOWN_ARROW) { | ||
this._focusNextItem(); | ||
} else if (event.keyCode === UP_ARROW) { | ||
this._focusPreviousItem(); | ||
} else if (event.keyCode === TAB) { | ||
this._handleTabKeypress(event.shiftKey); | ||
} | ||
} | ||
|
||
/** | ||
* When the tab key is pressed (changing focus natively), this function syncs up | ||
* the focusedItemIndex to match the currently focused item. If the shift key is | ||
* also pressed, we know that the focus has changed to the previous tabindex. If not, | ||
* focus has changed to the next tabindex. | ||
*/ | ||
private _handleTabKeypress(shiftPressed: boolean): void { | ||
shiftPressed ? this._updateFocusedItemIndex(-1) : this._updateFocusedItemIndex(1); | ||
} | ||
|
||
/** | ||
* This emits a close event to which the trigger is subscribed. When emitted, the | ||
* trigger will close the menu. | ||
*/ | ||
private _emitCloseEvent(): void { | ||
this._focusedItemIndex = 0; | ||
this.close.emit(null); | ||
} | ||
|
||
// When focus would shift past the start or end of the menu, close the menu. | ||
private _closeIfFocusLeavesMenu(): void { | ||
if (this._focusedItemIndex >= this.items.length || this._focusedItemIndex < 0) { | ||
this._emitCloseEvent(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do you emit a close event here? AFAICT, it doesn't actually close the menu, does it? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you end up removing this emit, I think you can omit this function entirely and simply prevent the focus from ever going out-of-bounds in the first place. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It does close the menu. If someone tries to tab or arrow past the menu items in either direction, the menu should close. The trigger is subscribed to the "close" event. When it's emitted from the menu, the trigger will close it. |
||
} | ||
} | ||
|
||
private _focusNextItem(): void { | ||
this._updateFocusedItemIndex(1); | ||
this.items.toArray()[this._focusedItemIndex].focus(); | ||
} | ||
|
||
private _focusPreviousItem(): void { | ||
this._updateFocusedItemIndex(-1); | ||
this.items.toArray()[this._focusedItemIndex].focus(); | ||
} | ||
|
||
private _updateFocusedItemIndex(delta: number, itemArray: MdMenuItem[] = this.items.toArray()) { | ||
this._focusedItemIndex += delta; | ||
this._closeIfFocusLeavesMenu(); | ||
|
||
// skip all disabled menu items recursively until an active one | ||
// is reached or the menu closes for overreaching bounds | ||
while (itemArray[this._focusedItemIndex].disabled) { | ||
this._updateFocusedItemIndex(delta, itemArray); | ||
} | ||
} | ||
|
||
private _setPositionX(pos: MenuPositionX): void { | ||
if ( pos !== 'before' && pos !== 'after') { | ||
throw new MdMenuInvalidPositionX(); | ||
|
@@ -78,8 +149,4 @@ export class MdMenu { | |
} | ||
this.positionY = pos; | ||
} | ||
|
||
private _emitCloseEvent(): void { | ||
this.close.emit(null); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Have you found it generally more difficult to add unit tests for the menu than e2e tests? The e2e tests are good at capturing the "real" behavior, but they take much longer to run (and are flakier), so covering as much of the behavior as possible in unit tests will generally make maintenance much easier later.