Skip to content

Commit

Permalink
Added keyboard navigation functionality to the TabList per the WAI AR…
Browse files Browse the repository at this point in the history
…IA Tab List Design Pattern requirements
  • Loading branch information
Erin Doyle committed Sep 2, 2018
1 parent 4b4d855 commit 9579770
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 40 deletions.
7 changes: 5 additions & 2 deletions public/app.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
:root {
--dark-blue: #034f9a;
--light-grey: #dee2e6;
--white: #ffffff;
}

a {
Expand Down Expand Up @@ -34,11 +36,12 @@ a {
.nav-tabs .nav-link {
text-decoration: none;
opacity: 1;
border-bottom: 1px solid #dee2e6;
border-bottom: 1px solid var(--light-grey);
background-color: var(--white);
}

.nav-tabs .nav-item.show .nav-link, .nav-tabs .nav-link.active {
border: 1px solid #dee2e6;
border: 1px solid var(--light-grey);
border-top-left-radius: .25rem;
border-top-right-radius: .25rem;
border-bottom: none;
Expand Down
14 changes: 7 additions & 7 deletions src/browse/MovieBrowser.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@ const MovieBrowser = ({
const selectedGenre = match.params.genre;
const goToWishlist = () => history.push('/wishlist');

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

<main>
<TabList ariaLabel="Movie Genres" activeTab={selectedGenre} tabList={tabList} />
<TabList ariaLabel="Movie Genres" tabList={tabList} />

<div
id={`${selectedGenre}-panel`}
Expand Down
202 changes: 175 additions & 27 deletions src/primitives/TabList.js
Original file line number Diff line number Diff line change
@@ -1,41 +1,189 @@
import React from 'react';
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { NavLink } from 'react-router-dom';
import { withRouter } from 'react-router-dom';


const TabList = ({ ariaLabel, activeTab, tabList }) => {
const tabItems = tabList.map((tabItem) => {
const { id, title, linkTo } = tabItem;
const isActiveTab = id === activeTab;
class TabList extends Component {
constructor(props) {
super(props);

const { tabList, match } = this.props;

this.state = {
// Default the selectedTab to the one matching the current URL (which matches the tabpanel content)
selectedTab: tabList.find((tab) => tab.linkTo === match.url) || tabList[0]
};

this.selectedTabRef = null;

this.setSelectedTabRef = this.setSelectedTabRef.bind(this);
this.selectTab = this.selectTab.bind(this);
this.gotoFirstTab = this.gotoFirstTab.bind(this);
this.gotoLastTab = this.gotoLastTab.bind(this);
this.gotoPreviousTab = this.gotoPreviousTab.bind(this);
this.gotoNextTab = this.gotoNextTab.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
}

componentDidUpdate() {
if (!this.selectedTabRef) return;

this.selectedTabRef.focus();
}

setSelectedTabRef(element) {
this.selectedTabRef = element;
}

selectTab (tab) {
const { history } = this.props;

this.setState({selectedTab: tab});

// Navigate to the selected tab's URL in order to display it in the tabpanel
history.push(tab.linkTo);
}

gotoFirstTab () {
const { tabList } = this.props;
this.selectTab(tabList[0]);
}

gotoLastTab () {
const { tabList } = this.props;
this.selectTab(tabList[tabList.length - 1]);
}

gotoPreviousTab (currentTab) {
const { tabList } = this.props;
const index = tabList.findIndex((tab) => tab === currentTab);

// If the current tab is already the first tab, circle round to the last tab
if (index === 0) {
this.gotoLastTab();
} else {
// Else go to the previous tab
this.selectTab(tabList[index - 1]);
}
}

gotoNextTab (currentTab) {
const { tabList } = this.props;
const index = tabList.findIndex((tab) => tab === currentTab);

// If the current tab is already the last tab, circle round to the first tab
if (index === tabList.length - 1) {
this.gotoFirstTab();
} else {
// Else go to the next tab
this.selectTab(tabList[index + 1]);
}
}

handleClick (e, tab) {
e.preventDefault();
this.selectTab(tab)
}

/**
* Per the WAI ARIA Tab List Design Pattern the following interaction is supported:
*
* When focus is on a tab element in a horizontal tab list:
* Left Arrow: moves focus to the previous tab. If focus is on the first tab, moves focus to the last tab.
* Right Arrow: Moves focus to the next tab. If focus is on the last tab element, moves focus to the first tab.
*
* When focus is on a tab in a tablist with either horizontal or vertical orientation:
* Space or Enter: Activates the tab if it was not activated automatically on focus.
* Home (Optional): Moves focus to the first tab.
* End (Optional): Moves focus to the last tab.
*
* WAI ARIA recommendation is that when a tab receives focus it "automatically activates" the newly focused tab.
*/
handleKeydown (e, tab) {
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
this.gotoPreviousTab(tab);
break;

case 'ArrowRight':
e.preventDefault();
this.gotoNextTab(tab);
break;

case 'Home':
e.preventDefault();
this.gotoFirstTab();
break;

case 'End':
e.preventDefault();
this.gotoLastTab();
break;

case 'Enter':
case ' ':
case 'Spacebar': // for older browsers
e.preventDefault();
this.selectTab(tab);
break;

default:
break;
}
}

render() {
const { ariaLabel, tabList } = this.props;
const { selectedTab } = this.state;

const tabItems = tabList.map((tabItem) => {
const { name, title } = tabItem;
const isSelectedTab = tabItem.name === selectedTab.name;
const tabClass = isSelectedTab ? 'nav-item nav-link active' : 'nav-item nav-link';

return (
<button
key={`${name}-tab`}
id={`${name}-tab`}
className={tabClass}

role="tab"
aria-selected={isSelectedTab}
aria-controls={isSelectedTab ? `${name}-panel` : null}
tabIndex={isSelectedTab ? 0 : -1}

onClick={e => this.handleClick(e, tabItem)}
onKeyDown={e => this.handleKeydown(e, tabItem)}

ref={ref => { if (isSelectedTab) this.setSelectedTabRef(ref); }}
>
{title}
</button>
);
});

return (
<li key={`${id}-tab`}
id={`${id}-tab`}
className="nav-item"
role="tab"
aria-selected={isActiveTab}
aria-controls={`${id}-panel`}
>
<NavLink to={linkTo} className="nav-link" activeClassName="active">{title}</NavLink>
</li>
<div className="nav nav-tabs nav-justified" role="tablist" aria-label={ariaLabel} tabIndex="0">
{tabItems}
</div>
);
});

return (
<ul className="nav nav-tabs nav-justified" role="tablist" aria-label={ariaLabel}>
{tabItems}
</ul>
);
};
}
}

TabList.propTypes = {
ariaLabel: PropTypes.string.isRequired,
activeTab: PropTypes.string.isRequired,
tabList: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
linkTo: PropTypes.string,
title: PropTypes.string
})).isRequired
})).isRequired,
// supplied by withRouter
match: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
};


export default TabList;
export default withRouter(TabList);
8 changes: 4 additions & 4 deletions src/wishlist/MovieWishlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ class MovieWishlist extends Component {
const selectedStatus = match.params.status;
const goToBrowse = () => history.push('/browse');

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

<TabList ariaLabel="WishLists by Status" activeTab={selectedStatus} tabList={tabList} />
<TabList ariaLabel="WishLists by Status" tabList={tabList} />

<div
id={`${selectedStatus}-panel`}
Expand Down

0 comments on commit 9579770

Please sign in to comment.