From 8cd66422515c70f3cfef7791921dbcb333f81c8e Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 27 Nov 2016 16:40:21 +0100 Subject: [PATCH] feat(list): add keyboard navigation * Adds the ability to switch focus between list items using the up/down keys. * Splits up the various list-related components into separate files. Fixes #941. --- e2e/components/list/list.e2e.ts | 34 +++++++++++++ src/e2e-app/list/list-e2e.html | 1 + src/lib/list/index.ts | 21 +++++++- src/lib/list/list-directives.ts | 9 ++++ src/lib/list/list-item.ts | 58 ++++++++++++++++++++++ src/lib/list/list.scss | 1 + src/lib/list/list.spec.ts | 21 +++++++- src/lib/list/list.ts | 87 ++++++++++----------------------- 8 files changed, 168 insertions(+), 64 deletions(-) create mode 100644 src/lib/list/list-directives.ts create mode 100644 src/lib/list/list-item.ts diff --git a/e2e/components/list/list.e2e.ts b/e2e/components/list/list.e2e.ts index 708ff9943ef0..6133737ad21e 100644 --- a/e2e/components/list/list.e2e.ts +++ b/e2e/components/list/list.e2e.ts @@ -9,4 +9,38 @@ describe('list', () => { let container = element(by.css('md-list')); expect(container.isElementPresent(by.css('md-list-item'))).toBe(true); }); + + it('should be tabbable', () => { + pressKey(protractor.Key.TAB); + expectFocusOn(element(by.css('md-list'))); + }); + + it('should shift focus between the list items', () => { + let items = element.all(by.css('md-list-item')); + + pressKey(protractor.Key.TAB); + pressKey(protractor.Key.DOWN); + expectFocusOn(items.get(0)); + + pressKey(protractor.Key.DOWN); + expectFocusOn(items.get(1)); + + pressKey(protractor.Key.DOWN); + expectFocusOn(items.get(2)); + + pressKey(protractor.Key.UP); + expectFocusOn(items.get(1)); + + pressKey(protractor.Key.UP); + expectFocusOn(items.get(0)); + }); + + // TODO: move to utility file. this was taken from the menu-page.ts + function expectFocusOn(el: any): void { + expect(browser.driver.switchTo().activeElement().getInnerHtml()).toBe(el.getInnerHtml()); + } + + function pressKey(key: string) { + browser.actions().sendKeys(key).perform(); + } }); diff --git a/src/e2e-app/list/list-e2e.html b/src/e2e-app/list/list-e2e.html index 676a29d5dd92..12ff3ccdd5ca 100644 --- a/src/e2e-app/list/list-e2e.html +++ b/src/e2e-app/list/list-e2e.html @@ -2,4 +2,5 @@

Items

Item one Item two + Item three diff --git a/src/lib/list/index.ts b/src/lib/list/index.ts index 71825137f468..4e413160dd25 100644 --- a/src/lib/list/index.ts +++ b/src/lib/list/index.ts @@ -1 +1,20 @@ -export * from './list'; +import {NgModule, ModuleWithProviders} from '@angular/core'; +import {MdLineModule} from '../core'; +import {MdList} from './list'; +import {MdListItem} from './list-item'; +import {MdListDivider, MdListAvatar} from './list-directives'; + + +@NgModule({ + imports: [MdLineModule], + exports: [MdList, MdListItem, MdListDivider, MdListAvatar, MdLineModule], + declarations: [MdList, MdListItem, MdListDivider, MdListAvatar], +}) +export class MdListModule { + static forRoot(): ModuleWithProviders { + return { + ngModule: MdListModule, + providers: [] + }; + } +} diff --git a/src/lib/list/list-directives.ts b/src/lib/list/list-directives.ts new file mode 100644 index 000000000000..0749995f3138 --- /dev/null +++ b/src/lib/list/list-directives.ts @@ -0,0 +1,9 @@ +import {Directive} from '@angular/core'; + + +@Directive({ selector: 'md-divider' }) +export class MdListDivider { } + +/* Need directive for a ContentChild query in list-item */ +@Directive({ selector: '[md-list-avatar]' }) +export class MdListAvatar { } diff --git a/src/lib/list/list-item.ts b/src/lib/list/list-item.ts new file mode 100644 index 000000000000..0501b36f14a2 --- /dev/null +++ b/src/lib/list/list-item.ts @@ -0,0 +1,58 @@ +import { + Component, + ViewEncapsulation, + ContentChildren, + ContentChild, + QueryList, + ElementRef, + Renderer, + AfterContentInit, +} from '@angular/core'; +import {MdLine, MdLineSetter} from '../core'; +import {MdListAvatar} from './list-directives'; +import {MdFocusable} from '../core/a11y/list-key-manager'; + + +@Component({ + moduleId: module.id, + selector: 'md-list-item, a[md-list-item]', + host: { + 'role': 'listitem', + '(focus)': '_handleFocus()', + '(blur)': '_handleBlur()', + 'tabIndex': '-1' + }, + templateUrl: 'list-item.html', + encapsulation: ViewEncapsulation.None +}) +export class MdListItem implements AfterContentInit, MdFocusable { + _hasFocus: boolean = false; + + private _lineSetter: MdLineSetter; + + @ContentChildren(MdLine) _lines: QueryList; + + @ContentChild(MdListAvatar) + set _hasAvatar(avatar: MdListAvatar) { + this._renderer.setElementClass(this._element.nativeElement, 'md-list-avatar', avatar != null); + } + + constructor(private _renderer: Renderer, private _element: ElementRef) { } + + /** TODO: internal */ + ngAfterContentInit() { + this._lineSetter = new MdLineSetter(this._lines, this._renderer, this._element); + } + + _handleFocus() { + this._hasFocus = true; + } + + _handleBlur() { + this._hasFocus = false; + } + + focus() { + this._renderer.invokeElementMethod(this._element.nativeElement, 'focus'); + } +} diff --git a/src/lib/list/list.scss b/src/lib/list/list.scss index 064c7e6dd6af..dcde4a9643f7 100644 --- a/src/lib/list/list.scss +++ b/src/lib/list/list.scss @@ -106,6 +106,7 @@ $md-dense-three-line-height: 76px; md-list, md-nav-list { padding-top: $md-list-top-padding; display: block; + outline: 0; [md-subheader] { @include md-subheader-base( diff --git a/src/lib/list/list.spec.ts b/src/lib/list/list.spec.ts index d8ebef1c604b..f4a41e1ccb45 100644 --- a/src/lib/list/list.spec.ts +++ b/src/lib/list/list.spec.ts @@ -1,7 +1,8 @@ import {async, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; -import {MdListItem, MdListModule} from './list'; +import {MdListModule} from './index'; +import {MdListItem} from './list-item'; describe('MdList', () => { @@ -19,6 +20,7 @@ describe('MdList', () => { ListWithDynamicNumberOfLines, ListWithMultipleItems, ListWithManyLines, + ListWithTabIndex, ], }); @@ -114,6 +116,15 @@ describe('MdList', () => { expect(list.nativeElement.getAttribute('role')).toBe('list'); expect(listItem.nativeElement.getAttribute('role')).toBe('listitem'); }); + + it('should forward the tabindex to the list element', () => { + let fixture = TestBed.createComponent(ListWithTabIndex); + let list = fixture.debugElement.children[0].nativeElement; + + fixture.detectChanges(); + + expect(list.getAttribute('tabindex')).toBe('1'); + }); }); @@ -211,3 +222,11 @@ class ListWithDynamicNumberOfLines extends BaseTestList { } `}) class ListWithMultipleItems extends BaseTestList { } + +@Component({template: ` + + + Paprika + + `}) +class ListWithTabIndex extends BaseTestList { } diff --git a/src/lib/list/list.ts b/src/lib/list/list.ts index 755b40ed0721..0faab15afcbf 100644 --- a/src/lib/list/list.ts +++ b/src/lib/list/list.ts @@ -2,86 +2,49 @@ import { Component, ViewEncapsulation, ContentChildren, - ContentChild, QueryList, - Directive, ElementRef, - Renderer, AfterContentInit, - NgModule, - ModuleWithProviders, + Input, } from '@angular/core'; -import {MdLine, MdLineSetter, MdLineModule} from '../core'; +import {ListKeyManager} from '../core/a11y/list-key-manager'; +import {DOWN_ARROW} from '../core/keyboard/keycodes'; +import {MdListItem} from './list-item'; -@Directive({ - selector: 'md-divider' -}) -export class MdListDivider {} @Component({ moduleId: module.id, selector: 'md-list, md-nav-list', - host: {'role': 'list'}, - template: '', - styleUrls: ['list.css'], - encapsulation: ViewEncapsulation.None -}) -export class MdList {} - -/* Need directive for a ContentChild query in list-item */ -@Directive({ selector: '[md-list-avatar]' }) -export class MdListAvatar {} - -@Component({ - moduleId: module.id, - selector: 'md-list-item, a[md-list-item]', host: { - 'role': 'listitem', - '(focus)': '_handleFocus()', - '(blur)': '_handleBlur()', + 'role': 'list', + '[attr.tabIndex]': 'tabindex', + '(keydown)': '_handleKeydown($event)' }, - templateUrl: 'list-item.html', + template: '', + styleUrls: ['list.css'], encapsulation: ViewEncapsulation.None }) -export class MdListItem implements AfterContentInit { - _hasFocus: boolean = false; +export class MdList implements AfterContentInit { + @ContentChildren(MdListItem) _items: QueryList; + @Input() tabindex: number = 0; - private _lineSetter: MdLineSetter; + /** Manages the keyboard events between list items. */ + private _keyManager: ListKeyManager; - @ContentChildren(MdLine) _lines: QueryList; + constructor(private _elementRef: ElementRef) { } - @ContentChild(MdListAvatar) - set _hasAvatar(avatar: MdListAvatar) { - this._renderer.setElementClass(this._element.nativeElement, 'md-list-avatar', avatar != null); - } - - constructor(private _renderer: Renderer, private _element: ElementRef) {} - - /** TODO: internal */ ngAfterContentInit() { - this._lineSetter = new MdLineSetter(this._lines, this._renderer, this._element); - } - - _handleFocus() { - this._hasFocus = true; + this._keyManager = new ListKeyManager(this._items); } - _handleBlur() { - this._hasFocus = false; - } -} - - -@NgModule({ - imports: [MdLineModule], - exports: [MdList, MdListItem, MdListDivider, MdListAvatar, MdLineModule], - declarations: [MdList, MdListItem, MdListDivider, MdListAvatar], -}) -export class MdListModule { - static forRoot(): ModuleWithProviders { - return { - ngModule: MdListModule, - providers: [] - }; + /** + * Shifts focus to the appropriate list item. + */ + _handleKeydown(event: KeyboardEvent) { + if (event.target === this._elementRef.nativeElement && event.keyCode === DOWN_ARROW) { + this._keyManager.focusFirstItem(); + } else { + this._keyManager.onKeydown(event); + } } }