├── .babelrc ├── .eslintignore ├── .eslintrc.json ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── __tests__ ├── action.js ├── connectHoverable.jsx ├── index.js └── reducer.js ├── package.json └── src ├── action.js ├── connectHoverable.js ├── index.js └── reducer.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "react", "stage-0"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | coverage 2 | lib 3 | node_modules 4 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "plugins": [ 5 | "react" 6 | ], 7 | "env": { 8 | "jest": true 9 | }, 10 | "rules": { 11 | "consistent-return": 0, 12 | "func-names": 0, 13 | "no-param-reassign": 0, 14 | "no-underscore-dangle": 0, 15 | "space-before-function-paren": 0, 16 | "react/require-default-props": 0, 17 | "import/no-extraneous-dependencies": 0 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | npm-debug.log 4 | lib 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "7" 4 | - "6" 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Buffer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # buffer-redux-hover 2 | 3 | [![Build Status](https://travis-ci.org/bufferapp/buffer-redux-hover.svg?branch=master)](https://travis-ci.org/bufferapp/buffer-redux-hover) 4 | 5 | Keep react component hover state in redux 6 | 7 | ## Usage 8 | 9 | Combine the reducer under the `hover` state tree: 10 | 11 | ```js 12 | import { combineReducers } from 'redux'; 13 | import { reducer as hover } from '@bufferapp/redux-hover'; 14 | 15 | const app = combineReducers({ 16 | hover, // important to have this under the hover state tree 17 | }); 18 | 19 | export default app; 20 | ``` 21 | 22 | Create a component that has a `hovered`, `onMouseEnter` and `onMouseLeave` prop: 23 | 24 | ```js 25 | import React from 'react'; 26 | import { connectHoverable } from '@bufferapp/redux-hover'; 27 | 28 | const MyHoverableComponent = ({ 29 | hovered, // managed by redux-hover 30 | onMouseEnter, 31 | onMouseLeave, 32 | }) => { 33 | const style = { 34 | background: hovered ? 'red' : 'blue', 35 | }; 36 | return ( 37 |
43 | Hover This 44 |
45 | ); 46 | }; 47 | 48 | MyHoverableComponent.propTypes = { 49 | hoverId: PropTypes.oneOfType([ 50 | PropTypes.string, 51 | PropTypes.number, 52 | ]), 53 | hovered: PropTypes.bool, 54 | onMouseEnter: PropTypes.func, 55 | onMouseLeave: PropTypes.func, 56 | }; 57 | 58 | export default connectHoverable(MyHoverableComponent); 59 | ``` 60 | 61 | Place `MyHoverableComponent` on the page and set a `hoverId`: 62 | 63 | ```js 64 | import React from 'react'; 65 | import MyHoverableComponent from './MyHoverableComponent'; 66 | 67 | const App = () => 68 |
69 | 70 | 71 |
; 72 | 73 | export default App; 74 | ``` 75 | 76 | ## Notes 77 | 78 | ### hovered prop 79 | 80 | The hovered prop is set to `true` on `MyHoverableComponent` when the mouse is hovering over it. Otherwise it's set to false. 81 | 82 | ### Choosing hoverId's 83 | 84 | As long as id's are different, they'll be independently hoverable. The above example sets the strings manually, but you could also use a `uuid()` too. 85 | 86 | This also means that ids with the same value will all get the hover state applied when any of them are hovered. 87 | 88 | ### MyHoverableComponent is cloned with `React.clone` 89 | 90 | This keeps the number of elements on the page minimal but adds a little overhead to clone the hoverable component. 91 | 92 | ### Manually Dispatching Actions For Hover Or Unhover 93 | 94 | Sometimes there's cases where manually dispatching an action might be necessary. A good example is a button that removes itself (clearing a todo list item for example). The actions are exposed for this purpose so they can be dispatched: 95 | 96 | ```js 97 | import { 98 | unhover, 99 | } from '@bufferapp/redux-hover'; 100 | 101 | export const REMOVE_TODO = 'REMOVE_TODO'; 102 | 103 | export const removeTodo = todoListId => dispatch => 104 | Promise.all([ 105 | dispatch({ 106 | type: REMOVE_TODO, 107 | todoListId, 108 | }), 109 | dispatch(unhover(`todo-list-item/remove-${todoListId}`)), 110 | ]); 111 | ``` 112 | -------------------------------------------------------------------------------- /__tests__/action.js: -------------------------------------------------------------------------------- 1 | import { 2 | HOVER, 3 | UNHOVER, 4 | hover, 5 | unhover, 6 | } from '../src/action'; 7 | 8 | describe('action', () => { 9 | it('HOVER action type exists', () => { 10 | expect(HOVER) 11 | .toBe('HOVER'); 12 | }); 13 | it('UNHOVER action type exists', () => { 14 | expect(UNHOVER) 15 | .toBe('UNHOVER'); 16 | }); 17 | describe('hover', () => { 18 | it('should create expected action', () => { 19 | const hoverId = 'some id'; 20 | expect(hover(hoverId)) 21 | .toEqual({ 22 | type: HOVER, 23 | hoverId, 24 | }); 25 | }); 26 | }); 27 | describe('unhover', () => { 28 | it('should create expected action', () => { 29 | const hoverId = 'some id'; 30 | expect(unhover(hoverId)) 31 | .toEqual({ 32 | type: UNHOVER, 33 | hoverId, 34 | }); 35 | }); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /__tests__/connectHoverable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { mount } from 'enzyme'; 4 | import connectHoverable from '../src/connectHoverable'; 5 | import { 6 | HOVER, 7 | UNHOVER, 8 | } from '../src/action'; 9 | 10 | const storeFake = state => ({ 11 | default: () => {}, 12 | subscribe: () => {}, 13 | dispatch: jest.fn(), 14 | getState: () => ({ ...state }), 15 | }); 16 | 17 | /* eslint-disable react/prop-types */ 18 | 19 | const TestComponent = ({ 20 | children, 21 | hover, 22 | onMouseEnter, 23 | onMouseLeave, 24 | }) => 25 |
30 | {children} 31 |
; 32 | 33 | const HoverableComponent = connectHoverable(TestComponent); 34 | 35 | /* eslint-enable react/prop-types */ 36 | 37 | describe('HoverableContainer', () => { 38 | it('should render a container component', () => { 39 | const text = 'Hi!'; 40 | const store = storeFake({}); 41 | const wrapper = mount( 42 | 43 | {text} 44 | , 45 | ); 46 | 47 | expect(wrapper.find(TestComponent).text()) 48 | .toBe(text); 49 | }); 50 | 51 | it('should render a hovered component', () => { 52 | const text = 'Hi!'; 53 | const hoverId = 'someid'; 54 | const store = storeFake({ 55 | hover: { 56 | [hoverId]: true, 57 | }, 58 | }); 59 | const wrapper = mount( 60 | 61 | {text} 62 | , 63 | ); 64 | 65 | expect(wrapper.find(TestComponent).props().hovered) 66 | .toBe(true); 67 | }); 68 | 69 | it('should dispatch onMouseEnter event', () => { 70 | const text = 'Hi!'; 71 | const hoverId = 'someid'; 72 | const store = storeFake({}); 73 | const wrapper = mount( 74 | 75 | {text} 76 | , 77 | ); 78 | wrapper.find(HoverableComponent).simulate('mouseEnter'); 79 | expect(store.dispatch) 80 | .toBeCalledWith({ 81 | type: HOVER, 82 | hoverId, 83 | }); 84 | }); 85 | 86 | it('should dispatch onMouseLeave event', () => { 87 | const text = 'Hi!'; 88 | const hoverId = 'someid'; 89 | const store = storeFake({}); 90 | const wrapper = mount( 91 | 92 | {text} 93 | , 94 | ); 95 | wrapper.find(HoverableComponent).simulate('mouseLeave'); 96 | expect(store.dispatch) 97 | .toBeCalledWith({ 98 | type: UNHOVER, 99 | hoverId, 100 | }); 101 | }); 102 | 103 | it('should not dispatch onMouseEnter event without hoverId', () => { 104 | const text = 'Hi!'; 105 | const store = storeFake({}); 106 | const wrapper = mount( 107 | 108 | {text} 109 | , 110 | ); 111 | wrapper.find(HoverableComponent).simulate('mouseEnter'); 112 | expect(store.dispatch) 113 | .not.toBeCalled(); 114 | }); 115 | 116 | it('should not dispatch onMouseLeave event without hoverId', () => { 117 | const text = 'Hi!'; 118 | const store = storeFake({}); 119 | const wrapper = mount( 120 | 121 | {text} 122 | , 123 | ); 124 | wrapper.find(HoverableComponent).simulate('mouseLeave'); 125 | expect(store.dispatch) 126 | .not.toBeCalled(); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /__tests__/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | reducer, 3 | connectHoverable, 4 | hover, 5 | unhover, 6 | } from '../src/index'; 7 | 8 | describe('index', () => { 9 | it('should export the reducer', () => { 10 | expect(reducer) 11 | .toBeDefined(); 12 | }); 13 | 14 | it('should export the HoverableContainer', () => { 15 | expect(connectHoverable) 16 | .toBeDefined(); 17 | }); 18 | it('should export hover action', () => { 19 | expect(hover) 20 | .toBeDefined(); 21 | }); 22 | it('should export unhover action', () => { 23 | expect(unhover) 24 | .toBeDefined(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /__tests__/reducer.js: -------------------------------------------------------------------------------- 1 | import reducer from '../src/reducer'; 2 | import { 3 | HOVER, 4 | UNHOVER, 5 | } from '../src/action'; 6 | 7 | describe('reducer', () => { 8 | it('should return initial state', () => { 9 | expect(reducer(undefined, {})) 10 | .toEqual({}); 11 | }); 12 | it('should handle HOVER', () => { 13 | const hoverId = 'some hoverId'; 14 | expect(reducer(undefined, { 15 | type: HOVER, 16 | hoverId, 17 | })) 18 | .toEqual({ 19 | [hoverId]: true, 20 | }); 21 | }); 22 | it('should handle hover with existing hover states', () => { 23 | const hoverId = 'some hoverId'; 24 | const otherId = 'other hoverId'; 25 | const initialState = { [otherId]: true }; 26 | expect(reducer(initialState, { 27 | type: HOVER, 28 | hoverId, 29 | })) 30 | .toEqual({ 31 | ...initialState, 32 | [hoverId]: true, 33 | }); 34 | }); 35 | it('should handle UNHOVER', () => { 36 | const hoverId = 'some hoverId'; 37 | const initialState = { [hoverId]: true }; 38 | expect(reducer(initialState, { 39 | type: UNHOVER, 40 | hoverId, 41 | })) 42 | .toEqual({}); 43 | }); 44 | it('should handle missing UNHOVER hoverId', () => { 45 | const hoverId = 'some hoverId'; 46 | const initialState = {}; 47 | expect(reducer(initialState, { 48 | type: UNHOVER, 49 | hoverId, 50 | })) 51 | .toEqual({}); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bufferapp/redux-hover", 3 | "version": "0.0.6", 4 | "description": "Keep React component hover state in redux", 5 | "main": "./lib/index.js", 6 | "scripts": { 7 | "build": "babel src --out-dir lib", 8 | "lint": "eslint .", 9 | "test": "npm run lint && jest --coverage", 10 | "test-watch": "jest --watch", 11 | "prepublish": "npm run build" 12 | }, 13 | "jest": { 14 | "coverageThreshold": { 15 | "global": { 16 | "branches": 100, 17 | "functions": 100, 18 | "lines": 100, 19 | "statements": 100 20 | } 21 | } 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/bufferapp/buffer-redux-hover.git" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "redux", 30 | "hover" 31 | ], 32 | "author": "Harrison Harnisch (http://hharnisc.github.io)", 33 | "license": "MIT", 34 | "bugs": { 35 | "url": "https://github.com/bufferapp/buffer-redux-hover/issues" 36 | }, 37 | "homepage": "https://github.com/bufferapp/buffer-redux-hover#readme", 38 | "peerDependencies": { 39 | "react-redux": ">= 4.4.5" 40 | }, 41 | "devDependencies": { 42 | "babel-cli": "^6.23.0", 43 | "babel-eslint": "^7.1.1", 44 | "babel-jest": "^18.0.0", 45 | "babel-preset-es2015": "^6.22.0", 46 | "babel-preset-react": "^6.23.0", 47 | "babel-preset-stage-0": "^6.22.0", 48 | "enzyme": "^2.7.1", 49 | "eslint": "^3.15.0", 50 | "eslint-config-airbnb": "^14.1.0", 51 | "eslint-plugin-import": "^2.2.0", 52 | "eslint-plugin-jsx-a11y": "^4.0.0", 53 | "eslint-plugin-react": "^6.9.0", 54 | "jest": "^18.1.0", 55 | "react": "^15.4.2", 56 | "react-addons-test-utils": "^15.4.2", 57 | "react-dom": "^15.4.2", 58 | "react-redux": "^5.0.2", 59 | "react-test-renderer": "^15.4.2", 60 | "redux": "^3.6.0" 61 | }, 62 | "files": [ 63 | "lib/", 64 | "src/", 65 | "README.md", 66 | "LICENSE" 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /src/action.js: -------------------------------------------------------------------------------- 1 | export const HOVER = 'HOVER'; 2 | export const UNHOVER = 'UNHOVER'; 3 | 4 | export const hover = hoverId => ({ 5 | type: HOVER, 6 | hoverId, 7 | }); 8 | 9 | export const unhover = hoverId => ({ 10 | type: UNHOVER, 11 | hoverId, 12 | }); 13 | -------------------------------------------------------------------------------- /src/connectHoverable.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { 3 | hover, 4 | unhover, 5 | } from './action'; 6 | 7 | const connectHoverable = (component) => { 8 | const mapStateToProps = (state, ownProps) => ({ 9 | hovered: ownProps.hoverId && state.hover ? state.hover[ownProps.hoverId] : false, 10 | }); 11 | 12 | const mapDispatchToProps = (dispatch, ownProps) => ({ 13 | onMouseEnter: () => (ownProps.hoverId ? dispatch(hover(ownProps.hoverId)) : null), 14 | onMouseLeave: () => (ownProps.hoverId ? dispatch(unhover(ownProps.hoverId)) : null), 15 | }); 16 | 17 | return connect( 18 | mapStateToProps, 19 | mapDispatchToProps, 20 | )(component); 21 | }; 22 | 23 | export default connectHoverable; 24 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export reducer from './reducer'; 2 | export connectHoverable from './connectHoverable'; 3 | export { 4 | hover, 5 | unhover, 6 | } from './action'; 7 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | HOVER, 3 | UNHOVER, 4 | } from './action'; 5 | 6 | const reducer = (state = {}, action) => { 7 | switch (action.type) { 8 | case HOVER: 9 | return { ...state, [action.hoverId]: true }; 10 | case UNHOVER: { 11 | const newState = { ...state }; 12 | delete newState[action.hoverId]; 13 | return newState; 14 | } 15 | default: 16 | return state; 17 | } 18 | }; 19 | 20 | export default reducer; 21 | --------------------------------------------------------------------------------