From c0c94947ada40cc1ec0a19e80bf79704c9f045fa Mon Sep 17 00:00:00 2001 From: Erin Doyle Date: Sun, 19 Aug 2018 17:36:18 -0400 Subject: [PATCH] Added a MovieToolbar component containing a toolbar widget for the Movie actions buttons --- src/primitives/MovieToolbar.js | 181 +++++++++++++++++++++++++++ src/primitives/MovieToolbarButton.js | 31 ++++- src/wishlist/getWishlistActions.js | 14 ++- 3 files changed, 216 insertions(+), 10 deletions(-) create mode 100644 src/primitives/MovieToolbar.js diff --git a/src/primitives/MovieToolbar.js b/src/primitives/MovieToolbar.js new file mode 100644 index 0000000..8131e9b --- /dev/null +++ b/src/primitives/MovieToolbar.js @@ -0,0 +1,181 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import MovieToolbarButton from './MovieToolbarButton'; + + +class MovieToolbar extends Component { + constructor(props) { + super(props); + + const { buttonList } = this.props; + + this.state = { + selectedButton: buttonList[0] + }; + + this.selectedButtonRef = null; + + this.setSelectedButtonRef = this.setSelectedButtonRef.bind(this); + this.selectButton = this.selectButton.bind(this); + this.gotoFirstButton = this.gotoFirstButton.bind(this); + this.gotoLastButton = this.gotoLastButton.bind(this); + this.gotoPreviousButton = this.gotoPreviousButton.bind(this); + this.gotoNextButton = this.gotoNextButton.bind(this); + this.handleClick = this.handleClick.bind(this); + this.handleKeydown = this.handleKeydown.bind(this); + } + + componentDidUpdate() { + if (!this.selectedButtonRef) return; + + this.selectedButtonRef.focus(); + } + + setSelectedButtonRef(element) { + this.selectedButtonRef = element; + } + + selectButton (button) { + this.setState({selectedButton: button}); + } + + gotoFirstButton () { + const { buttonList } = this.props; + this.selectButton(buttonList[0]); + } + + gotoLastButton () { + const { buttonList } = this.props; + this.selectButton(buttonList[buttonList.length - 1]); + } + + gotoPreviousButton (currentButton) { + const { buttonList } = this.props; + const index = buttonList.findIndex((button) => button === currentButton); + + // If the current button is already the first button, circle round to the last button + if (index === 0) { + this.gotoLastButton(); + } else { + // Else go to the previous button + this.selectButton(buttonList[index - 1]); + } + } + + gotoNextButton (currentButton) { + const { buttonList } = this.props; + const index = buttonList.findIndex((button) => button === currentButton); + + // If the current button is already the last button, circle round to the first button + if (index === buttonList.length - 1) { + this.gotoFirstButton(); + } else { + // Else go to the next button + this.selectButton(buttonList[index + 1]); + } + } + + handleClick (e, button) { + e.preventDefault(); + this.selectButton(button); + + // Fire the button's action + button.action(); + } + + /** + * Per the WAI ARIA Button List Design Pattern the following interaction is supported: + * + * When focus is on a button element in a horizontal button list: + * Left Arrow: moves focus to the previous button. If focus is on the first button, moves focus to the last button. + * Right Arrow: Moves focus to the next button. If focus is on the last button element, moves focus to the first button. + * + * When focus is on a button in a buttonlist with either horizontal or vertical orientation: + * Space or Enter: Activates the button if it was not activated automatically on focus. + * Home (Optional): Moves focus to the first button. + * End (Optional): Moves focus to the last button. + * + * WAI ARIA recommendation is that when a button receives focus it "automatically activates" the newly focused button. + */ + handleKeydown (e, button) { + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + this.gotoPreviousButton(button); + break; + + case 'ArrowRight': + e.preventDefault(); + this.gotoNextButton(button); + break; + + case 'Home': + e.preventDefault(); + this.gotoFirstButton(); + break; + + case 'End': + e.preventDefault(); + this.gotoLastButton(); + break; + + case 'Enter': + case ' ': + case 'Spacebar': // for older browsers + e.preventDefault(); + this.selectButton(button); + + // Fire the button's action + button.action(); + break; + + default: + break; + } + } + + render() { + const { ariaLabel, movieTitle, buttonList } = this.props; + const { selectedButton } = this.state; + + const buttonItems = buttonList.map((buttonItem) => { + const { title } = buttonItem; + const isSelectedButton = buttonItem.title === selectedButton.title; + + return ( + this.handleClick(e, buttonItem)} + keyDownHandler={e => this.handleKeydown(e, buttonItem)} + + innerRef={ref => { if (isSelectedButton) this.setSelectedButtonRef(ref); }} + /> + ); + }); + + return ( +
+ {buttonItems} +
+ ); + } +} + +MovieToolbar.propTypes = { + ariaLabel: PropTypes.string.isRequired, + movieTitle: PropTypes.string.isRequired, + buttonList: PropTypes.arrayOf(PropTypes.shape({ + title: PropTypes.string, + action: PropTypes.func + })).isRequired +}; + + +export default MovieToolbar; diff --git a/src/primitives/MovieToolbarButton.js b/src/primitives/MovieToolbarButton.js index c2d70e6..311ebba 100644 --- a/src/primitives/MovieToolbarButton.js +++ b/src/primitives/MovieToolbarButton.js @@ -2,25 +2,48 @@ import React from 'react'; import PropTypes from 'prop-types'; -const MovieToolbarButton = ({ movieTitle, buttonText, buttonLabel, clickHandler }) => { +const MovieToolbarButton = ({ + movieTitle, + buttonText, + buttonLabel, + clickHandler, + keyDownHandler, + tabIndex, + innerRef +}) => { const ariaLabel = buttonLabel || `${buttonText} ${movieTitle}`; return ( - + ); }; MovieToolbarButton.defaultProps = { buttonText: '', buttonLabel: null, - clickHandler: () => {} + clickHandler: () => {}, + keyDownHandler: () => {}, + tabIndex: 0, + innerRef: () => {} }; MovieToolbarButton.propTypes = { movieTitle: PropTypes.string.isRequired, buttonText: PropTypes.string, buttonLabel: PropTypes.string, - clickHandler: PropTypes.func + clickHandler: PropTypes.func, + keyDownHandler: PropTypes.func, + tabIndex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + innerRef: PropTypes.func }; diff --git a/src/wishlist/getWishlistActions.js b/src/wishlist/getWishlistActions.js index c4a034f..fe13c5e 100644 --- a/src/wishlist/getWishlistActions.js +++ b/src/wishlist/getWishlistActions.js @@ -1,5 +1,5 @@ import React from 'react'; -import MovieToolbarButton from '../primitives/MovieToolbarButton'; +import MovieToolbar from '../primitives/MovieToolbar'; const getMovieActions = (showEditor, setAsWatched, setAsUnwatched, handleRemove) => @@ -9,12 +9,14 @@ const getMovieActions = (showEditor, setAsWatched, setAsUnwatched, handleRemove) const editClickHandler = () => showEditor(movieId); const removeClickHandler = () => handleRemove(movieId); + const movieButtonList = [ + { title: watchButtonText, action: watchClickHandler }, + { title: 'Edit', action: editClickHandler }, + { title: 'Remove', action: removeClickHandler } + ]; + return ( -
- - - -
+ ); };