Skip to content

Commit

Permalink
feat(list): Add basic keyboard navigation to M3 list
Browse files Browse the repository at this point in the history
PiperOrigin-RevId: 465533534
  • Loading branch information
material-web-copybara authored and copybara-github committed Aug 5, 2022
1 parent 884c3a2 commit ee35bfe
Show file tree
Hide file tree
Showing 4 changed files with 164 additions and 4 deletions.
92 changes: 89 additions & 3 deletions list/lib/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,29 @@

import {ARIARole} from '@material/web/types/aria';
import {html, LitElement, PropertyValues, TemplateResult} from 'lit';
import {queryAssignedElements} from 'lit/decorators';
import {property, query, queryAssignedElements} from 'lit/decorators';

import {ListItemInteractionEvent} from './listitem/constants';
import {ListItem} from './listitem/list-item';

const NAVIGATABLE_KEYS = {
ArrowDown: 'ArrowDown',
ArrowUp: 'ArrowUp',
Home: 'Home',
End: 'End',
};

/** @soyCompatible */
export class List extends LitElement {
static override shadowRootOptions:
ShadowRootInit = {mode: 'open', delegatesFocus: true};

@property({type: Number}) listTabIndex: number = 0;

items: ListItem[] = [];
activeListItem: ListItem|null = null;

@query('.md3-list') listRoot!: HTMLElement;

@queryAssignedElements({flatten: true})
protected assignedElements!: HTMLElement[]|null;
Expand All @@ -36,20 +48,76 @@ export class List extends LitElement {
override render(): TemplateResult {
return html`
<ul class="md3-list"
tabindex="0"
tabindex=${this.listTabIndex}
role=${this.getAriaRole()}
@list-item-interaction=${this.handleItemInteraction}>
@list-item-interaction=${this.handleItemInteraction}
@keydown=${this.handleKeydown}
>
<slot></slot>
</ul>
`;
}

handleKeydown(event: KeyboardEvent) {
if (Object.values(NAVIGATABLE_KEYS).indexOf(event.key) === -1) return;

if (event.key === NAVIGATABLE_KEYS.ArrowDown) {
event.preventDefault();
if (this.activeListItem) {
this.activeListItem = this.getNextItem(this.activeListItem);
} else {
this.activeListItem = this.getFirstItem();
}
}

if (event.key === NAVIGATABLE_KEYS.ArrowUp) {
event.preventDefault();
if (this.activeListItem) {
this.activeListItem = this.getPrevItem(this.activeListItem);
} else {
this.activeListItem = this.getLastItem();
}
}

if (event.key === NAVIGATABLE_KEYS.Home) {
event.preventDefault();
this.activeListItem = this.getFirstItem();
}

if (event.key === NAVIGATABLE_KEYS.End) {
event.preventDefault();
this.activeListItem = this.getLastItem();
}

if (!this.activeListItem) return;

for (const item of this.items) {
item.deactivate();
}

this.activeListItem.activate();
}

handleItemInteraction(event: ListItemInteractionEvent) {
if (event.detail.state.isSelected) {
// TODO: manage selection state.
}
}

activateFirstItem() {
this.activeListItem = this.getFirstItem();
this.activeListItem.activate();
}

activateLastItem() {
this.activeListItem = this.getLastItem();
this.activeListItem.activate();
}

focusListRoot() {
this.listRoot.focus();
}

/** Updates `this.items` based on slot elements in the DOM. */
protected updateItems() {
const elements = this.assignedElements || [];
Expand All @@ -64,4 +132,22 @@ export class List extends LitElement {
private isListItem(element: Element): element is ListItem {
return element.tagName.toLowerCase() === this.getListItemTagName();
}

private getFirstItem(): ListItem {
return this.items[0];
}

private getLastItem(): ListItem {
return this.items[this.items.length - 1];
}

private getPrevItem(item: ListItem): ListItem {
const curIndex = this.items.indexOf(item);
return this.items[curIndex === 0 ? this.items.length - 1 : curIndex - 1];
}

private getNextItem(item: ListItem): ListItem {
const curIndex = this.items.indexOf(item);
return this.items[(curIndex + 1) % this.items.length];
}
}
14 changes: 13 additions & 1 deletion list/lib/listitem/list-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ export class ListItem extends ActionElement {
@property({type: String}) multiLineSupportingText = '';
@property({type: String}) trailingSupportingText = '';
@property({type: Boolean}) disabled = false;
@property({type: Number}) itemTabIndex = -1;
@property({type: String}) headline = '';
@query('md-ripple') ripple!: MdRipple;
@query('[data-query-md3-list-item]') listItemRoot!: HTMLElement;

/** @soyTemplate */
override render(): TemplateResult {
return html`
<li
tabindex="0"
tabindex=${this.itemTabIndex}
role=${this.getAriaRole()}
data-query-md3-list-item
class="md3-list-item ${classMap(this.getRenderClasses())}"
@pointerdown=${this.handlePointerDown}
@pointerenter=${this.handlePointerEnter}
Expand Down Expand Up @@ -166,4 +169,13 @@ export class ListItem extends ActionElement {
// TODO(b/240124486): Replace with beginPress provided by action element.
this.ripple.endPress();
}

activate() {
this.itemTabIndex = 0;
this.listItemRoot.focus();
}

deactivate() {
this.itemTabIndex = -1;
}
}
38 changes: 38 additions & 0 deletions list/list-item_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import './list-item';

import {Environment} from '@material/web/testing/environment';
import {html} from 'lit';

const LIST_ITEM_TEMPLATE = html`
<md-list-item>One</md-list-item>
`;

describe('list item tests', () => {
const env = new Environment();

it('`activate()` should focus the list item', async () => {
const listItem =
env.render(LIST_ITEM_TEMPLATE).querySelector('md-list-item')!;
await env.waitForStability();

listItem.activate();
expect(document.activeElement).toEqual(listItem);
});

it('`deactivate()` should set root tab index to -1', async () => {
const listItem =
env.render(LIST_ITEM_TEMPLATE).querySelector('md-list-item')!;
await env.waitForStability();

listItem.deactivate();
expect(listItem.shadowRoot!.querySelector('[tabindex]')!.getAttribute(
'tabindex'))
.toBe('-1');
});
});
24 changes: 24 additions & 0 deletions list/list_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,28 @@ describe('list tests', () => {

expect(element.items.length).toBe(3);
});

it('focusListRoot() should focus on the list element', async () => {
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
await env.waitForStability();

list.focusListRoot();
expect(document.activeElement).toEqual(list);
});

it('activateFirstItem() should focus on the first list item', async () => {
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
await env.waitForStability();

list.activateFirstItem();
expect(document.activeElement).toEqual(list.items[0]);
});

it('activateLastItem() should focus on the last list item', async () => {
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
await env.waitForStability();

list.activateLastItem();
expect(document.activeElement).toEqual(list.items[list.items.length - 1]);
});
});

0 comments on commit ee35bfe

Please sign in to comment.