diff --git a/client/package.json b/client/package.json index a9c70400..21e3f053 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/src/core-components/__tests__/drop-down-test.js b/client/src/core-components/__tests__/drop-down-test.js new file mode 100644 index 00000000..3776621e --- /dev/null +++ b/client/src/core-components/__tests__/drop-down-test.js @@ -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( + + ); + 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}); + }); +}); \ No newline at end of file diff --git a/client/src/core-components/__tests__/menu-test.js b/client/src/core-components/__tests__/menu-test.js index dfab6c97..0884a955 100644 --- a/client/src/core-components/__tests__/menu-test.js +++ b/client/src/core-components/__tests__/menu-test.js @@ -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])); }); diff --git a/client/src/core-components/button.scss b/client/src/core-components/button.scss index 89295775..d7514668 100644 --- a/client/src/core-components/button.scss +++ b/client/src/core-components/button.scss @@ -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 { diff --git a/client/src/core-components/checkbox.scss b/client/src/core-components/checkbox.scss index 1e1301d6..9e09a1c5 100644 --- a/client/src/core-components/checkbox.scss +++ b/client/src/core-components/checkbox.scss @@ -21,6 +21,10 @@ &_checked { .checkbox--icon { color: $primary-red; + + &:focus { + color: lighten($primary-red, 5%);; + } } } } \ No newline at end of file diff --git a/client/src/core-components/drop-down.js b/client/src/core-components/drop-down.js index 70c3500d..9bdc22ae 100644 --- a/client/src/core-components/drop-down.js +++ b/client/src/core-components/drop-down.js @@ -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 (
{this.renderCurrentItem(selectedItem)} - + {this.renderList.bind(this)}
@@ -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 (
- +
); } @@ -81,7 +80,7 @@ class DropDown extends React.Component { } return ( -
+
{iconNode}{item.content}
); @@ -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; diff --git a/client/src/core-components/menu.js b/client/src/core-components/menu.js index 1a102038..ff837dbb 100644 --- a/client/src/core-components/menu.js +++ b/client/src/core-components/menu.js @@ -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 }; } diff --git a/client/src/core-components/menu.scss b/client/src/core-components/menu.scss index 380a571f..a5f45593 100644 --- a/client/src/core-components/menu.scss +++ b/client/src/core-components/menu.scss @@ -13,6 +13,7 @@ &__list-item { padding: 8px; + transition: background-color 0.3s ease, color 0.3s ease; &_selected, &:hover { diff --git a/client/src/core-components/modal.js b/client/src/core-components/modal.js index 64fb8b15..6a6f21a7 100644 --- a/client/src/core-components/modal.js +++ b/client/src/core-components/modal.js @@ -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), diff --git a/client/src/reducers/config-reducer.js b/client/src/reducers/config-reducer.js index 832ec6ea..13abe25d 100644 --- a/client/src/reducers/config-reducer.js +++ b/client/src/reducers/config-reducer.js @@ -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 + }); } }