Skip to content

Commit ee35bfe

Browse files
feat(list): Add basic keyboard navigation to M3 list
PiperOrigin-RevId: 465533534
1 parent 884c3a2 commit ee35bfe

File tree

4 files changed

+164
-4
lines changed

4 files changed

+164
-4
lines changed

list/lib/list.ts

+89-3
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,29 @@
66

77
import {ARIARole} from '@material/web/types/aria';
88
import {html, LitElement, PropertyValues, TemplateResult} from 'lit';
9-
import {queryAssignedElements} from 'lit/decorators';
9+
import {property, query, queryAssignedElements} from 'lit/decorators';
1010

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

14+
const NAVIGATABLE_KEYS = {
15+
ArrowDown: 'ArrowDown',
16+
ArrowUp: 'ArrowUp',
17+
Home: 'Home',
18+
End: 'End',
19+
};
20+
1421
/** @soyCompatible */
1522
export class List extends LitElement {
1623
static override shadowRootOptions:
1724
ShadowRootInit = {mode: 'open', delegatesFocus: true};
1825

26+
@property({type: Number}) listTabIndex: number = 0;
27+
1928
items: ListItem[] = [];
29+
activeListItem: ListItem|null = null;
30+
31+
@query('.md3-list') listRoot!: HTMLElement;
2032

2133
@queryAssignedElements({flatten: true})
2234
protected assignedElements!: HTMLElement[]|null;
@@ -36,20 +48,76 @@ export class List extends LitElement {
3648
override render(): TemplateResult {
3749
return html`
3850
<ul class="md3-list"
39-
tabindex="0"
51+
tabindex=${this.listTabIndex}
4052
role=${this.getAriaRole()}
41-
@list-item-interaction=${this.handleItemInteraction}>
53+
@list-item-interaction=${this.handleItemInteraction}
54+
@keydown=${this.handleKeydown}
55+
>
4256
<slot></slot>
4357
</ul>
4458
`;
4559
}
4660

61+
handleKeydown(event: KeyboardEvent) {
62+
if (Object.values(NAVIGATABLE_KEYS).indexOf(event.key) === -1) return;
63+
64+
if (event.key === NAVIGATABLE_KEYS.ArrowDown) {
65+
event.preventDefault();
66+
if (this.activeListItem) {
67+
this.activeListItem = this.getNextItem(this.activeListItem);
68+
} else {
69+
this.activeListItem = this.getFirstItem();
70+
}
71+
}
72+
73+
if (event.key === NAVIGATABLE_KEYS.ArrowUp) {
74+
event.preventDefault();
75+
if (this.activeListItem) {
76+
this.activeListItem = this.getPrevItem(this.activeListItem);
77+
} else {
78+
this.activeListItem = this.getLastItem();
79+
}
80+
}
81+
82+
if (event.key === NAVIGATABLE_KEYS.Home) {
83+
event.preventDefault();
84+
this.activeListItem = this.getFirstItem();
85+
}
86+
87+
if (event.key === NAVIGATABLE_KEYS.End) {
88+
event.preventDefault();
89+
this.activeListItem = this.getLastItem();
90+
}
91+
92+
if (!this.activeListItem) return;
93+
94+
for (const item of this.items) {
95+
item.deactivate();
96+
}
97+
98+
this.activeListItem.activate();
99+
}
100+
47101
handleItemInteraction(event: ListItemInteractionEvent) {
48102
if (event.detail.state.isSelected) {
49103
// TODO: manage selection state.
50104
}
51105
}
52106

107+
activateFirstItem() {
108+
this.activeListItem = this.getFirstItem();
109+
this.activeListItem.activate();
110+
}
111+
112+
activateLastItem() {
113+
this.activeListItem = this.getLastItem();
114+
this.activeListItem.activate();
115+
}
116+
117+
focusListRoot() {
118+
this.listRoot.focus();
119+
}
120+
53121
/** Updates `this.items` based on slot elements in the DOM. */
54122
protected updateItems() {
55123
const elements = this.assignedElements || [];
@@ -64,4 +132,22 @@ export class List extends LitElement {
64132
private isListItem(element: Element): element is ListItem {
65133
return element.tagName.toLowerCase() === this.getListItemTagName();
66134
}
135+
136+
private getFirstItem(): ListItem {
137+
return this.items[0];
138+
}
139+
140+
private getLastItem(): ListItem {
141+
return this.items[this.items.length - 1];
142+
}
143+
144+
private getPrevItem(item: ListItem): ListItem {
145+
const curIndex = this.items.indexOf(item);
146+
return this.items[curIndex === 0 ? this.items.length - 1 : curIndex - 1];
147+
}
148+
149+
private getNextItem(item: ListItem): ListItem {
150+
const curIndex = this.items.indexOf(item);
151+
return this.items[(curIndex + 1) % this.items.length];
152+
}
67153
}

list/lib/listitem/list-item.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,18 @@ export class ListItem extends ActionElement {
1919
@property({type: String}) multiLineSupportingText = '';
2020
@property({type: String}) trailingSupportingText = '';
2121
@property({type: Boolean}) disabled = false;
22+
@property({type: Number}) itemTabIndex = -1;
2223
@property({type: String}) headline = '';
2324
@query('md-ripple') ripple!: MdRipple;
25+
@query('[data-query-md3-list-item]') listItemRoot!: HTMLElement;
2426

2527
/** @soyTemplate */
2628
override render(): TemplateResult {
2729
return html`
2830
<li
29-
tabindex="0"
31+
tabindex=${this.itemTabIndex}
3032
role=${this.getAriaRole()}
33+
data-query-md3-list-item
3134
class="md3-list-item ${classMap(this.getRenderClasses())}"
3235
@pointerdown=${this.handlePointerDown}
3336
@pointerenter=${this.handlePointerEnter}
@@ -166,4 +169,13 @@ export class ListItem extends ActionElement {
166169
// TODO(b/240124486): Replace with beginPress provided by action element.
167170
this.ripple.endPress();
168171
}
172+
173+
activate() {
174+
this.itemTabIndex = 0;
175+
this.listItemRoot.focus();
176+
}
177+
178+
deactivate() {
179+
this.itemTabIndex = -1;
180+
}
169181
}

list/list-item_test.ts

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* @license
3+
* Copyright 2022 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import './list-item';
8+
9+
import {Environment} from '@material/web/testing/environment';
10+
import {html} from 'lit';
11+
12+
const LIST_ITEM_TEMPLATE = html`
13+
<md-list-item>One</md-list-item>
14+
`;
15+
16+
describe('list item tests', () => {
17+
const env = new Environment();
18+
19+
it('`activate()` should focus the list item', async () => {
20+
const listItem =
21+
env.render(LIST_ITEM_TEMPLATE).querySelector('md-list-item')!;
22+
await env.waitForStability();
23+
24+
listItem.activate();
25+
expect(document.activeElement).toEqual(listItem);
26+
});
27+
28+
it('`deactivate()` should set root tab index to -1', async () => {
29+
const listItem =
30+
env.render(LIST_ITEM_TEMPLATE).querySelector('md-list-item')!;
31+
await env.waitForStability();
32+
33+
listItem.deactivate();
34+
expect(listItem.shadowRoot!.querySelector('[tabindex]')!.getAttribute(
35+
'tabindex'))
36+
.toBe('-1');
37+
});
38+
});

list/list_test.ts

+24
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,28 @@ describe('list tests', () => {
2727

2828
expect(element.items.length).toBe(3);
2929
});
30+
31+
it('focusListRoot() should focus on the list element', async () => {
32+
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
33+
await env.waitForStability();
34+
35+
list.focusListRoot();
36+
expect(document.activeElement).toEqual(list);
37+
});
38+
39+
it('activateFirstItem() should focus on the first list item', async () => {
40+
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
41+
await env.waitForStability();
42+
43+
list.activateFirstItem();
44+
expect(document.activeElement).toEqual(list.items[0]);
45+
});
46+
47+
it('activateLastItem() should focus on the last list item', async () => {
48+
const list = env.render(LIST_TEMPLATE).querySelector('md-list')!;
49+
await env.waitForStability();
50+
51+
list.activateLastItem();
52+
expect(document.activeElement).toEqual(list.items[list.items.length - 1]);
53+
});
3054
});

0 commit comments

Comments
 (0)