Skip to content

Commit 9579770

Browse files
author
Erin Doyle
committed
Added keyboard navigation functionality to the TabList per the WAI ARIA Tab List Design Pattern requirements
1 parent 4b4d855 commit 9579770

File tree

4 files changed

+191
-40
lines changed

4 files changed

+191
-40
lines changed

public/app.css

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
:root {
22
--dark-blue: #034f9a;
3+
--light-grey: #dee2e6;
4+
--white: #ffffff;
35
}
46

57
a {
@@ -34,11 +36,12 @@ a {
3436
.nav-tabs .nav-link {
3537
text-decoration: none;
3638
opacity: 1;
37-
border-bottom: 1px solid #dee2e6;
39+
border-bottom: 1px solid var(--light-grey);
40+
background-color: var(--white);
3841
}
3942

4043
.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active {
41-
border: 1px solid #dee2e6;
44+
border: 1px solid var(--light-grey);
4245
border-top-left-radius: .25rem;
4346
border-top-right-radius: .25rem;
4447
border-bottom: none;

src/browse/MovieBrowser.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ const MovieBrowser = ({
1919
const selectedGenre = match.params.genre;
2020
const goToWishlist = () => history.push('/wishlist');
2121

22-
// NOTE: id value should match :genre path in linkTo URL
22+
// NOTE: name value should match :genre path in linkTo URL
2323
// since we're using match.params.genre to identify the activeTab
2424
const tabList = [
25-
{ id: "action", linkTo: "/browse/action", title: "Action" },
26-
{ id: "drama", linkTo: "/browse/drama", title: "Drama" },
27-
{ id: "comedy", linkTo: "/browse/comedy", title: "Comedy" },
28-
{ id: "scifi", linkTo: "/browse/scifi", title: "Sci Fi" },
29-
{ id: "fantasy", linkTo: "/browse/fantasy", title: "Fantasy" }
25+
{ name: "action", linkTo: "/browse/action", title: "Action" },
26+
{ name: "drama", linkTo: "/browse/drama", title: "Drama" },
27+
{ name: "comedy", linkTo: "/browse/comedy", title: "Comedy" },
28+
{ name: "scifi", linkTo: "/browse/scifi", title: "Sci Fi" },
29+
{ name: "fantasy", linkTo: "/browse/fantasy", title: "Fantasy" }
3030
];
3131
const movieActions = getBrowseActions(addToWishlist, removeFromWishlist);
3232
const moviesInGenre = movies[selectedGenre];
@@ -36,7 +36,7 @@ const MovieBrowser = ({
3636
<Header title="Browse Movies" buttonText="< Back" buttonLabel="Back to Wish List" handleButtonClick={goToWishlist} />
3737

3838
<main>
39-
<TabList ariaLabel="Movie Genres" activeTab={selectedGenre} tabList={tabList} />
39+
<TabList ariaLabel="Movie Genres" tabList={tabList} />
4040

4141
<div
4242
id={`${selectedGenre}-panel`}

src/primitives/TabList.js

Lines changed: 175 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,189 @@
1-
import React from 'react';
1+
import React, { Component } from 'react';
22
import PropTypes from 'prop-types';
3-
import { NavLink } from 'react-router-dom';
3+
import { withRouter } from 'react-router-dom';
44

55

6-
const TabList = ({ ariaLabel, activeTab, tabList }) => {
7-
const tabItems = tabList.map((tabItem) => {
8-
const { id, title, linkTo } = tabItem;
9-
const isActiveTab = id === activeTab;
6+
class TabList extends Component {
7+
constructor(props) {
8+
super(props);
9+
10+
const { tabList, match } = this.props;
11+
12+
this.state = {
13+
// Default the selectedTab to the one matching the current URL (which matches the tabpanel content)
14+
selectedTab: tabList.find((tab) => tab.linkTo === match.url) || tabList[0]
15+
};
16+
17+
this.selectedTabRef = null;
18+
19+
this.setSelectedTabRef = this.setSelectedTabRef.bind(this);
20+
this.selectTab = this.selectTab.bind(this);
21+
this.gotoFirstTab = this.gotoFirstTab.bind(this);
22+
this.gotoLastTab = this.gotoLastTab.bind(this);
23+
this.gotoPreviousTab = this.gotoPreviousTab.bind(this);
24+
this.gotoNextTab = this.gotoNextTab.bind(this);
25+
this.handleClick = this.handleClick.bind(this);
26+
this.handleKeydown = this.handleKeydown.bind(this);
27+
}
28+
29+
componentDidUpdate() {
30+
if (!this.selectedTabRef) return;
31+
32+
this.selectedTabRef.focus();
33+
}
34+
35+
setSelectedTabRef(element) {
36+
this.selectedTabRef = element;
37+
}
38+
39+
selectTab (tab) {
40+
const { history } = this.props;
41+
42+
this.setState({selectedTab: tab});
43+
44+
// Navigate to the selected tab's URL in order to display it in the tabpanel
45+
history.push(tab.linkTo);
46+
}
47+
48+
gotoFirstTab () {
49+
const { tabList } = this.props;
50+
this.selectTab(tabList[0]);
51+
}
52+
53+
gotoLastTab () {
54+
const { tabList } = this.props;
55+
this.selectTab(tabList[tabList.length - 1]);
56+
}
57+
58+
gotoPreviousTab (currentTab) {
59+
const { tabList } = this.props;
60+
const index = tabList.findIndex((tab) => tab === currentTab);
61+
62+
// If the current tab is already the first tab, circle round to the last tab
63+
if (index === 0) {
64+
this.gotoLastTab();
65+
} else {
66+
// Else go to the previous tab
67+
this.selectTab(tabList[index - 1]);
68+
}
69+
}
70+
71+
gotoNextTab (currentTab) {
72+
const { tabList } = this.props;
73+
const index = tabList.findIndex((tab) => tab === currentTab);
74+
75+
// If the current tab is already the last tab, circle round to the first tab
76+
if (index === tabList.length - 1) {
77+
this.gotoFirstTab();
78+
} else {
79+
// Else go to the next tab
80+
this.selectTab(tabList[index + 1]);
81+
}
82+
}
83+
84+
handleClick (e, tab) {
85+
e.preventDefault();
86+
this.selectTab(tab)
87+
}
88+
89+
/**
90+
* Per the WAI ARIA Tab List Design Pattern the following interaction is supported:
91+
*
92+
* When focus is on a tab element in a horizontal tab list:
93+
* Left Arrow: moves focus to the previous tab. If focus is on the first tab, moves focus to the last tab.
94+
* Right Arrow: Moves focus to the next tab. If focus is on the last tab element, moves focus to the first tab.
95+
*
96+
* When focus is on a tab in a tablist with either horizontal or vertical orientation:
97+
* Space or Enter: Activates the tab if it was not activated automatically on focus.
98+
* Home (Optional): Moves focus to the first tab.
99+
* End (Optional): Moves focus to the last tab.
100+
*
101+
* WAI ARIA recommendation is that when a tab receives focus it "automatically activates" the newly focused tab.
102+
*/
103+
handleKeydown (e, tab) {
104+
switch (e.key) {
105+
case 'ArrowLeft':
106+
e.preventDefault();
107+
this.gotoPreviousTab(tab);
108+
break;
109+
110+
case 'ArrowRight':
111+
e.preventDefault();
112+
this.gotoNextTab(tab);
113+
break;
114+
115+
case 'Home':
116+
e.preventDefault();
117+
this.gotoFirstTab();
118+
break;
119+
120+
case 'End':
121+
e.preventDefault();
122+
this.gotoLastTab();
123+
break;
124+
125+
case 'Enter':
126+
case ' ':
127+
case 'Spacebar': // for older browsers
128+
e.preventDefault();
129+
this.selectTab(tab);
130+
break;
131+
132+
default:
133+
break;
134+
}
135+
}
136+
137+
render() {
138+
const { ariaLabel, tabList } = this.props;
139+
const { selectedTab } = this.state;
140+
141+
const tabItems = tabList.map((tabItem) => {
142+
const { name, title } = tabItem;
143+
const isSelectedTab = tabItem.name === selectedTab.name;
144+
const tabClass = isSelectedTab ? 'nav-item nav-link active' : 'nav-item nav-link';
145+
146+
return (
147+
<button
148+
key={`${name}-tab`}
149+
id={`${name}-tab`}
150+
className={tabClass}
151+
152+
role="tab"
153+
aria-selected={isSelectedTab}
154+
aria-controls={isSelectedTab ? `${name}-panel` : null}
155+
tabIndex={isSelectedTab ? 0 : -1}
156+
157+
onClick={e => this.handleClick(e, tabItem)}
158+
onKeyDown={e => this.handleKeydown(e, tabItem)}
159+
160+
ref={ref => { if (isSelectedTab) this.setSelectedTabRef(ref); }}
161+
>
162+
{title}
163+
</button>
164+
);
165+
});
166+
10167
return (
11-
<li key={`${id}-tab`}
12-
id={`${id}-tab`}
13-
className="nav-item"
14-
role="tab"
15-
aria-selected={isActiveTab}
16-
aria-controls={`${id}-panel`}
17-
>
18-
<NavLink to={linkTo} className="nav-link" activeClassName="active">{title}</NavLink>
19-
</li>
168+
<div className="nav nav-tabs nav-justified" role="tablist" aria-label={ariaLabel} tabIndex="0">
169+
{tabItems}
170+
</div>
20171
);
21-
});
22-
23-
return (
24-
<ul className="nav nav-tabs nav-justified" role="tablist" aria-label={ariaLabel}>
25-
{tabItems}
26-
</ul>
27-
);
28-
};
172+
}
173+
}
29174

30175
TabList.propTypes = {
31176
ariaLabel: PropTypes.string.isRequired,
32-
activeTab: PropTypes.string.isRequired,
33177
tabList: PropTypes.arrayOf(PropTypes.shape({
34-
id: PropTypes.string,
178+
name: PropTypes.string,
35179
linkTo: PropTypes.string,
36180
title: PropTypes.string
37-
})).isRequired
181+
})).isRequired,
182+
// supplied by withRouter
183+
match: PropTypes.object.isRequired,
184+
location: PropTypes.object.isRequired,
185+
history: PropTypes.object.isRequired
38186
};
39187

40188

41-
export default TabList;
189+
export default withRouter(TabList);

src/wishlist/MovieWishlist.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ class MovieWishlist extends Component {
5959
const selectedStatus = match.params.status;
6060
const goToBrowse = () => history.push('/browse');
6161

62-
// NOTE: id value should match :status path in linkTo URL
62+
// NOTE: name value should match :status path in linkTo URL
6363
// since we're using match.params.status to identify the activeTab
6464
const tabList = [
65-
{ id: 'unwatched', linkTo: "/wishlist/unwatched", title: "Unwatched" },
66-
{ id: 'watched', linkTo: "/wishlist/watched", title: "Watched" }
65+
{ name: 'unwatched', linkTo: "/wishlist/unwatched", title: "Unwatched" },
66+
{ name: 'watched', linkTo: "/wishlist/watched", title: "Watched" }
6767
];
6868
const movieActions = getWishlistActions(this.handleShowEditor, setAsWatched, setAsUnwatched, removeMovie);
6969
const movieInEditing = movieIdInEdit ? wishlist[movieIdInEdit] : {};
@@ -77,7 +77,7 @@ class MovieWishlist extends Component {
7777
// Show WishList
7878
? <Fragment>
7979

80-
<TabList ariaLabel="WishLists by Status" activeTab={selectedStatus} tabList={tabList} />
80+
<TabList ariaLabel="WishLists by Status" tabList={tabList} />
8181

8282
<div
8383
id={`${selectedStatus}-panel`}

0 commit comments

Comments
 (0)