Merged in dropdown-accesibility (pull request #48)

Dropdown accesibility
This commit is contained in:
Ivan Diaz 2016-09-19 14:22:35 -03:00
commit be9919baef
10 changed files with 414 additions and 21 deletions

View File

@ -66,7 +66,7 @@
"react-document-title": "^1.0.2",
"react-dom": "^15.0.1",
"react-google-recaptcha": "^0.5.2",
"react-motion": "^0.3.0",
"react-motion": "^0.4.4",
"react-redux": "^4.4.5",
"react-router": "^2.4.0",
"react-router-redux": "^4.0.5",

View File

@ -0,0 +1,260 @@
// LIBS
const {Motion} = require('react-motion');
const _ = require('lodash');
// MOCKS
const Menu = ReactMock();
const Icon = ReactMock();
// COMPONENT
const DropDown = requireUnit('core-components/drop-down', {
'core-components/menu': Menu,
'core-components/icon': Icon
});
describe('DropDown component', function () {
let dropdown, menu, currentItem, menuMotion;
function renderDropDown(props) {
let defaultProps = {
items: [
{content: 'First Item', icon: 'ICON_1'},
{content: 'Second Item', icon: 'ICON_2'},
{content: 'Third Item', icon: 'ICON_3'},
{content: 'Fourth Item', icon: 'ICON_4'}
],
onChange: stub()
};
dropdown = TestUtils.renderIntoDocument(
<DropDown {..._.extend(defaultProps, props)} />
);
menu = TestUtils.scryRenderedComponentsWithType(dropdown, Menu)[0];
menuMotion = TestUtils.scryRenderedComponentsWithType(dropdown, Motion)[0];
currentItem = TestUtils.scryRenderedDOMComponentsWithClass(dropdown, 'drop-down__current-item')[0];
}
beforeEach(function() {
renderDropDown();
});
it('should render a current item and a Menu of items', function () {
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down');
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.textContent).to.equal('First Item');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
expect(currentItem.getAttribute('aria-autocomplete')).to.equal('list');
expect(currentItem.getAttribute('role')).to.equal('combobox');
expect(currentItem.getAttribute('tabindex')).to.equal('0');
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(menu.props.role).to.equal('listbox');
expect(menu.props.itemsRole).to.equal('option');
expect(menu.props.items).to.equal(dropdown.props.items);
expect(menu.props.selectedIndex).to.equal(0);
});
it('should open/close list when click on current item', function () {
TestUtils.Simulate.click(currentItem);
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
TestUtils.Simulate.click(currentItem);
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
});
it('should open/close list when pressing Enter on current item', function () {
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
});
it('should open list but no close when pressing Space on current item', function () {
TestUtils.Simulate.keyDown(currentItem, {key: 'Space', keyCode: 32, which: 32});
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
TestUtils.Simulate.keyDown(currentItem, {key: 'Space', keyCode: 32, which: 32});
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
});
it('should close the list with escape on current item', function () {
TestUtils.Simulate.keyDown(currentItem, {key: 'Space', keyCode: 32, which: 32});
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
TestUtils.Simulate.keyDown(currentItem, {key: 'Esc', keyCode: 27, which: 27});
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
});
it('should close the list when current item loses focus', function () {
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(menuMotion.props.style.opacity.val).to.equal(1);
expect(ReactDOM.findDOMNode(dropdown).className).to.not.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('true');
TestUtils.Simulate.blur(currentItem);
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
});
it('should change selection, close and call onChange when a menu item is clicked', function () {
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Space', keyCode: 32, which: 32});
menu.props.onItemClick(2);
// Should be closed
expect(menuMotion.props.style.opacity.val).to.equal(0);
expect(ReactDOM.findDOMNode(dropdown).className).to.contain('drop-down_closed');
expect(currentItem.getAttribute('aria-expanded')).to.equal('false');
expect(currentItem.textContent).to.equal('Third Item');
expect(menu.props.selectedIndex).to.equal(2);
expect(dropdown.props.onChange).to.have.been.calledWith({index: 2});
});
it('should only change menu section when using arrow keys', function () {
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(menu.props.selectedIndex).to.equal(0);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(1);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(2);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(3);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(0);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(1);
TestUtils.Simulate.keyDown(currentItem, {key: 'Up', keyCode: 38, which: 38});
expect(menu.props.selectedIndex).to.equal(0);
TestUtils.Simulate.keyDown(currentItem, {key: 'Up', keyCode: 38, which: 38});
expect(menu.props.selectedIndex).to.equal(3);
TestUtils.Simulate.keyDown(currentItem, {key: 'Up', keyCode: 38, which: 38});
expect(menu.props.selectedIndex).to.equal(2);
expect(dropdown.props.onChange).to.have.not.been.called;
expect(currentItem.textContent).to.equal('First Item');
});
it('should not change menu selection if it is closed', function () {
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(0);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(0);
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(0);
expect(dropdown.props.onChange).to.have.not.been.called;
expect(currentItem.textContent).to.equal('First Item');
});
it('should change selection to the menu\'s one, if Enter key is pressed', function () {
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
expect(menu.props.selectedIndex).to.equal(2);
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(currentItem.textContent).to.equal('Third Item');
expect(menu.props.selectedIndex).to.equal(2);
expect(dropdown.props.onChange).to.have.been.calledWith({index: 2});
});
it('should not change selection with esc, blur or space', function () {
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(menu.props.selectedIndex).to.equal(2);
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Esc', keyCode: 27, which: 27});
expect(currentItem.textContent).to.equal('Third Item');
expect(dropdown.props.onChange).to.have.not.been.called;
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Space', keyCode: 32, which: 32});
expect(currentItem.textContent).to.equal('Third Item');
expect(dropdown.props.onChange).to.have.not.been.called;
dropdown.props.onChange.reset();
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.blur(currentItem);
expect(currentItem.textContent).to.equal('Third Item');
expect(dropdown.props.onChange).to.have.not.been.called;
});
it('should start selecting defaultSelectedIndex', function () {
dropdown.props.onChange.reset();
renderDropDown({defaultSelectedIndex: 2});
expect(currentItem.textContent).to.equal('Third Item');
expect(menu.props.selectedIndex).to.equal(2);
expect(dropdown.props.onChange).to.have.not.been.called;
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(currentItem.textContent).to.equal('Fourth Item');
expect(menu.props.selectedIndex).to.equal(3);
expect(dropdown.props.onChange).to.have.been.calledWith({index: 3});
});
it('should only show selectedIndex prop on the current selection', function () {
dropdown.props.onChange.reset();
renderDropDown({selectedIndex: 2});
expect(currentItem.textContent).to.equal('Third Item');
expect(menu.props.selectedIndex).to.equal(2);
expect(dropdown.props.onChange).to.have.not.been.called;
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
TestUtils.Simulate.keyDown(currentItem, {key: 'Down', keyCode: 40, which: 40});
TestUtils.Simulate.keyDown(currentItem, {key: 'Enter', keyCode: 13, which: 13});
expect(currentItem.textContent).to.equal('Third Item');
expect(menu.props.selectedIndex).to.equal(3);
expect(dropdown.props.onChange).to.have.been.calledWith({index: 3});
});
});

View File

@ -37,7 +37,8 @@ describe('Menu component', function () {
{content: 'Second Item', icon: 'ICON_2'},
{content: 'Third Item', icon: 'ICON_3'},
{content: 'Fourth Item', icon: 'ICON_4'}
]
],
itemsRole: 'some_role'
});
expect(items.length).to.equal(4);
@ -47,6 +48,7 @@ describe('Menu component', function () {
expect(items[3].textContent).to.equal('Fourth Item');
items.forEach((item, index) => {
expect(item.getAttribute('role')).to.equal('some_role');
expect(item.className).to.contain('menu__list-item');
expect(item.childNodes[0]).to.equal(ReactDOM.findDOMNode(icons[index]));
});

View File

@ -5,12 +5,21 @@
&_primary,
&_secondary,
&_tertiary {
background-color: $primary-red;
border: solid transparent;
border-radius: 4px;
color: white;
height: 47px;
text-transform: uppercase;
transition: background-color 0.2s ease;
}
&_primary {
background-color: $primary-red;
&:focus, &:hover {
background-color: lighten($primary-red, 5%);
outline: none;
}
}
&_secondary {

View File

@ -21,6 +21,10 @@
&_checked {
.checkbox--icon {
color: $primary-red;
&:focus {
color: lighten($primary-red, 5%);;
}
}
}
}

View File

@ -1,6 +1,8 @@
import React from 'react';
import _ from 'lodash';
import classNames from 'classnames';
import {Motion, spring} from 'react-motion';
import keyCode from 'keycode';
import Menu from 'core-components/menu';
import Icon from 'core-components/icon';
@ -11,18 +13,21 @@ class DropDown extends React.Component {
defaultSelectedIndex: React.PropTypes.number,
selectedIndex: React.PropTypes.number,
items: Menu.propTypes.items,
onChange: React.PropTypes.func,
size: React.PropTypes.oneOf(['small', 'medium', 'large'])
};
static defaultProps = {
defaultSelectedIndex: 2
defaultSelectedIndex: 0
};
constructor(props) {
super(props);
this.state = {
selectedIndex: 0,
menuId: _.uniqueId('drop-down-menu_'),
selectedIndex: props.selectedIndex || props.defaultSelectedIndex,
highlightedIndex: props.selectedIndex || props.defaultSelectedIndex,
opened: false
};
}
@ -38,7 +43,7 @@ class DropDown extends React.Component {
};
return {
defaultStyle: closedStyle,
defaultStyle: {opacity: 0, translateY: 20},
style: (this.state.opened) ? openedStyle : closedStyle
};
}
@ -50,7 +55,7 @@ class DropDown extends React.Component {
return (
<div className={this.getClass()}>
{this.renderCurrentItem(selectedItem)}
<Motion defaultStyle={animation.defaultStyle} style={animation.style}>
<Motion defaultStyle={animation.defaultStyle} style={animation.style} onRest={this.onAnimationFinished.bind(this)}>
{this.renderList.bind(this)}
</Motion>
</div>
@ -59,16 +64,10 @@ class DropDown extends React.Component {
renderList({opacity, translateY}) {
let style = { opacity: opacity, transform: `translateY(${translateY}px)`};
let menuProps = {
items: this.props.items,
onItemClick: this.handleItemClick.bind(this),
onMouseDown: this.handleListMouseDown.bind(this),
selectedIndex: this.getSelectedIndex()
};
return (
<div className="drop-down__list-container" style={style}>
<Menu {...menuProps} />
<Menu {...this.getMenuProps()} />
</div>
);
}
@ -81,7 +80,7 @@ class DropDown extends React.Component {
}
return (
<div className="drop-down__current-item" onBlur={this.handleBlur.bind(this)} onClick={this.handleClick.bind(this)} tabIndex="0">
<div {...this.getCurrentItemProps()}>
{iconNode}{item.content}
</div>
);
@ -92,13 +91,103 @@ class DropDown extends React.Component {
'drop-down': true,
'drop-down_closed': !this.state.opened,
['drop-down_' + this.props.size]: true,
['drop-down_' + this.props.size]: (this.props.size),
[this.props.className]: (this.props.className)
};
return classNames(classes);
}
getCurrentItemProps() {
return {
'aria-expanded': this.state.opened,
'aria-autocomplete': 'list',
'aria-owns': this.state.menuId,
'aria-activedescendant': this.state.menuId + '__' + this.state.highlightedIndex,
className: 'drop-down__current-item',
onClick: this.handleClick.bind(this),
onKeyDown: this.onKeyDown.bind(this),
onBlur: this.handleBlur.bind(this),
role: 'combobox',
tabIndex: 0
};
}
getMenuProps() {
return {
id: this.state.menuId,
itemsRole: 'option',
items: this.props.items,
onItemClick: this.handleItemClick.bind(this),
onMouseDown: this.handleListMouseDown.bind(this),
selectedIndex: this.state.highlightedIndex,
role: 'listbox'
};
}
onKeyDown(event) {
const keyActions = this.getKeyActions(event);
const keyAction = keyActions[keyCode(event)];
if (keyAction) {
keyAction();
}
}
getKeyActions(event) {
const {highlightedIndex, opened} = this.state;
const itemsQuantity = this.props.items.length;
return {
'up': () => {
if (opened) {
event.preventDefault();
this.setState({
highlightedIndex: this.modulo(highlightedIndex - 1, itemsQuantity)
});
}
},
'down': () => {
if (opened) {
event.preventDefault();
this.setState({
highlightedIndex: this.modulo(highlightedIndex + 1, itemsQuantity)
});
}
},
'enter': () => {
if (opened) {
this.onIndexSelected(highlightedIndex);
} else {
this.setState({
opened: true
});
}
},
'space': () => {
event.preventDefault();
this.setState({
opened: true
});
},
'esc': () => {
this.setState({
opened: false
});
},
'tab': () => {
if (this.state.opened) {
event.preventDefault();
this.onIndexSelected(highlightedIndex)
}
}
};
}
handleBlur() {
this.setState({
opened: false
@ -112,9 +201,14 @@ class DropDown extends React.Component {
}
handleItemClick(index) {
this.onIndexSelected(index);
}
onIndexSelected(index) {
this.setState({
opened: false,
selectedIndex: index
selectedIndex: index,
highlightedIndex: index
});
if (this.props.onChange) {
@ -128,9 +222,21 @@ class DropDown extends React.Component {
event.preventDefault();
}
onAnimationFinished() {
if (!this.state.opened && this.state.highlightedIndex !== this.getSelectedIndex()) {
this.setState({
highlightedIndex: this.getSelectedIndex()
});
}
}
getSelectedIndex() {
return (this.props.selectedIndex !== undefined) ? this.props.selectedIndex : this.state.selectedIndex;
}
modulo(number, mod) {
return ((number % mod) + mod) % mod;
}
}
export default DropDown;

View File

@ -8,6 +8,8 @@ import Icon from 'core-components/icon';
class Menu extends React.Component {
static propTypes = {
id: React.PropTypes.string,
itemsRole: React.PropTypes.string,
header: React.PropTypes.string,
type: React.PropTypes.oneOf(['primary', 'secondary']),
items: React.PropTypes.arrayOf(React.PropTypes.shape({
@ -64,6 +66,7 @@ class Menu extends React.Component {
props.className = 'menu__list';
delete props.itemsRole;
delete props.header;
delete props.items;
delete props.onItemClick;
@ -87,10 +90,12 @@ class Menu extends React.Component {
getItemProps(index) {
return {
id: this.props.id + '__' + index,
className: this.getItemClass(index),
onClick: this.onItemClick.bind(this, index),
tabIndex: (this.props.tabbable) ? '0' : null,
onKeyDown: this.onKeyDown.bind(this, index),
role: this.props.itemsRole,
key: index
};
}

View File

@ -13,6 +13,7 @@
&__list-item {
padding: 8px;
transition: background-color 0.3s ease, color 0.3s ease;
&_selected,
&:hover {

View File

@ -18,8 +18,8 @@ class Modal extends React.Component {
getAnimations() {
return {
defaultStyle: {
scale: spring(0.7),
fade: spring(0.5)
scale: 0.7,
fade: 0.5
},
style: {
scale: spring(1),

View File

@ -27,9 +27,15 @@ class ConfigReducer extends Reducer {
}
onInitConfigs(state, payload) {
sessionStore.storeConfigs(payload.data);
const currentLanguage = sessionStore.getItem('language');
return _.extend({}, state, payload.data);
sessionStore.storeConfigs(_.extend(payload.data, {
language: currentLanguage || payload.language
}));
return _.extend({}, state, payload.data, {
language: currentLanguage || payload.language
});
}
}