Skip to content
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

feat(list): add keyboard navigation #1999

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions e2e/components/list/list.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
});
1 change: 1 addition & 0 deletions src/e2e-app/list/list-e2e.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
<h3 md-subheader>Items</h3>
<md-list-item>Item one</md-list-item>
<md-list-item>Item two</md-list-item>
<md-list-item>Item three</md-list-item>
</md-list>
21 changes: 20 additions & 1 deletion src/lib/list/index.ts
Original file line number Diff line number Diff line change
@@ -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: []
};
}
}
9 changes: 9 additions & 0 deletions src/lib/list/list-directives.ts
Original file line number Diff line number Diff line change
@@ -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 { }
58 changes: 58 additions & 0 deletions src/lib/list/list-item.ts
Original file line number Diff line number Diff line change
@@ -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<MdLine>;

@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');
}
}
1 change: 1 addition & 0 deletions src/lib/list/list.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
21 changes: 20 additions & 1 deletion src/lib/list/list.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -19,6 +20,7 @@ describe('MdList', () => {
ListWithDynamicNumberOfLines,
ListWithMultipleItems,
ListWithManyLines,
ListWithTabIndex,
],
});

Expand Down Expand Up @@ -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');
});
});


Expand Down Expand Up @@ -211,3 +222,11 @@ class ListWithDynamicNumberOfLines extends BaseTestList { }
</md-list-item>
</md-list>`})
class ListWithMultipleItems extends BaseTestList { }

@Component({template: `
<md-list tabindex="1">
<md-list-item>
Paprika
</md-list-item>
</md-list>`})
class ListWithTabIndex extends BaseTestList { }
87 changes: 25 additions & 62 deletions src/lib/list/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '<ng-content></ng-content>',
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: '<ng-content></ng-content>',
styleUrls: ['list.css'],
encapsulation: ViewEncapsulation.None
})
export class MdListItem implements AfterContentInit {
_hasFocus: boolean = false;
export class MdList implements AfterContentInit {
@ContentChildren(MdListItem) _items: QueryList<MdListItem>;
@Input() tabindex: number = 0;

private _lineSetter: MdLineSetter;
/** Manages the keyboard events between list items. */
private _keyManager: ListKeyManager;

@ContentChildren(MdLine) _lines: QueryList<MdLine>;
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);
}
}
}