├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── example ├── app.js ├── index.html ├── keymap.js ├── main.js └── main.less ├── package.json ├── src ├── component │ ├── index.js │ └── shortcuts.js ├── helpers.js ├── index.js ├── shortcut-manager.js └── utils.js ├── test ├── keymap.js ├── mocha.opts ├── shortcut-manager.spec.js ├── shortcuts.spec.js └── utils.js ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"], 3 | "plugins": ["add-module-exports"], 4 | "env": { 5 | "production": { 6 | "presets": ["react-optimize"] 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "plugins": [ 4 | "import", 5 | "react" 6 | ], 7 | "extends": [ "airbnb" ], 8 | "env": { 9 | "browser": true, 10 | "mocha": true, 11 | "node": true 12 | }, 13 | "rules": { 14 | "array-bracket-spacing": 0, 15 | "arrow-body-style": 0, 16 | "comma-dangle": [ 2, "always-multiline" ], 17 | "consistent-return": 0, 18 | "default-case": 0, 19 | "dot-notation": 0, 20 | "func-names": 0, 21 | "global-require": 0, 22 | "import/default": 2, 23 | "import/export": 2, 24 | "import/imports-first": 0, 25 | "import/named": 2, 26 | "import/namespace": 2, 27 | "import/no-extraneous-dependencies": [ 28 | "warn", { 29 | "devDependencies": true 30 | } 31 | ], 32 | "import/no-unresolved": [ 33 | "error", { 34 | "commonjs": true, 35 | "amd": true 36 | } 37 | ], 38 | "import/prefer-default-export": 1, 39 | "jsx-quotes": 0, 40 | "new-cap": 0, 41 | "max-len": 0, 42 | "no-console": 0, 43 | "no-fallthrough": 1, 44 | "no-global-assign": 0, 45 | "no-irregular-whitespace": [ 46 | "error", { 47 | "skipStrings": true, 48 | "skipTemplates": true, 49 | "skipRegExps": true 50 | } 51 | ], 52 | "no-lonely-if": 0, 53 | "no-param-reassign": 0, 54 | "no-shadow": 1, 55 | "no-underscore-dangle": 0, 56 | "no-unsafe-negation": 0, 57 | "no-unused-expressions": 0, 58 | "no-unused-vars": [ 59 | "warn", { 60 | "vars": "all", 61 | "args": "none" 62 | } 63 | ], 64 | "no-use-before-define": [ 65 | "warn", { 66 | "functions": false, 67 | "classes": true 68 | } 69 | ], 70 | "quote-props": 0, 71 | "react/no-find-dom-node": 1, 72 | "react/prop-types": 1, 73 | "react/no-did-mount-set-state": 1, 74 | "react/no-did-update-set-state": 1, 75 | "react/prefer-stateless-function": 0, 76 | "react/jsx-curly-spacing": 0, 77 | "react/jsx-no-bind": 0, 78 | "react/jsx-filename-extension": 0, 79 | "semi": [ 2, "never" ], 80 | }, 81 | "globals": { 82 | "require": false, 83 | "ga": false 84 | }, 85 | "settings": { 86 | "import/ignore": [ 87 | "node_modules", 88 | "\\.json$" 89 | ], 90 | "import/resolver": { 91 | "webpack": { 92 | "config": "webpack-js.config.js" 93 | } 94 | }, 95 | "import/parser": "babel-eslint", 96 | } 97 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS garbage 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # built sources 6 | dist/ 7 | lib/ 8 | 9 | # npm stuff 10 | node_modules/ 11 | npm-debug.log 12 | coverage/ 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | example 3 | test 4 | *.coffee 5 | *.sh 6 | *.md 7 | *.yml 8 | webpack.config.js 9 | coverage 10 | dist 11 | src 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "12" 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Petr Brzek 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Shortcuts 2 | ========= 3 | 4 | **Manage keyboard shortcuts from one place.** 5 | 6 | [![Build Status](https://travis-ci.org/avocode/react-shortcuts.svg)][travis] 7 | 8 | 9 | Intro 10 | ------ 11 | 12 | 13 | Managing keyboard shortcuts can sometimes get messy. Or always, if not implemented the right way. 14 | 15 | Real problems: 16 | 17 | - You can't easily tell which shortcut is bound to which component 18 | - You have to write a lot of boilerplate code (`addEventListeners`, `removeEventListeners`, ...) 19 | - Memory leaks are a real problem if components don’t remove their listeners properly 20 | - Platform specific shortcuts is another headache 21 | - It's more difficult to implement feature like user-defined shortcuts 22 | - You can't easily get allthe application shortcuts and display it (e.g. in settings) 23 | 24 | 25 | **React shortcuts to the rescue!** 26 | ----------- 27 | 28 | With `react-shortcuts` you can declaratively manage shortcuts for each one of your React components. 29 | 30 | **Important parts of React Shortcuts:** 31 | 32 | - Your `keymap` definition 33 | - `ShortcutManager` which handles `keymap` 34 | - `` component for handling shortcuts 35 | 36 | 37 | Try online demo 38 | ------- 39 | 40 | [![Edit l40jjo48nl](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/l40jjo48nl) 41 | 42 | 43 | Quick tour 44 | ---------- 45 | 46 | 47 | #### 1. `npm install react-shortcuts` 48 | 49 | 50 | #### 2. **Define application shortcuts** 51 | 52 | Create a new JS, Coffee, JSON or CSON file wherever you want (which probably is your project root). And define the shortcuts for your React component. 53 | 54 | **Keymap definition** 55 | 56 | ```json 57 | { 58 | "Namespace": { 59 | "Action": "Shortcut", 60 | "Action_2": ["Shortcut", "Shortcut"], 61 | "Action_3": { 62 | "osx": "Shortcut", 63 | "windows": ["Shortcut", "Shortcut"], 64 | "linux": "Shortcut", 65 | "other": "Shortcut" 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | - `Namespace` should ideally be the component’s `displayName`. 72 | - `Action` describes what will be happening. For example `MODAL_CLOSE`. 73 | - `Keyboard shortcut` can be a string, array of strings or an object which 74 | specifies platform differences (Windows, OSX, Linux, other). The 75 | shortcut may be composed of single keys (`a`, `6`,…), combinations 76 | (`command+shift+k`) or sequences (`up up down down left right left right B A`). 77 | 78 | > **Combokeys** is used under the 79 | hood for handling the shortcuts. [Read more][mousetrap] about how you can 80 | specify keys. 81 | 82 | 83 | ##### Example `keymap` definition: 84 | 85 | 86 | ```javascript 87 | export default { 88 | TODO_ITEM: { 89 | MOVE_LEFT: 'left', 90 | MOVE_RIGHT: 'right', 91 | MOVE_UP: ['up', 'w'], 92 | DELETE: { 93 | osx: ['command+backspace', 'k'], 94 | windows: 'delete', 95 | linux: 'delete', 96 | }, 97 | }, 98 | } 99 | 100 | ``` 101 | 102 | Save this file as `keymap.[js|coffee|json|cson]` and require it into your main 103 | file. 104 | 105 | ```javascript 106 | import keymap from './keymap' 107 | ``` 108 | 109 | #### 3. Rise of the ShortcutsManager 110 | 111 | Define your keymap in whichever supported format but in the end it must be an 112 | object. `ShortcutsManager` can’t parse JSON and will certainly not be happy 113 | about the situation. 114 | 115 | ```javascript 116 | import keymap from './keymap' 117 | import { ShortcutManager } from 'react-shortcuts' 118 | 119 | const shortcutManager = new ShortcutManager(keymap) 120 | 121 | // Or like this 122 | 123 | const shortcutManager = new ShortcutManager() 124 | shortcutManager.setKeymap(keymap) 125 | ``` 126 | 127 | #### 4. Include `shortcutManager` into getChildContext of some parent component. So that `` can receive it. 128 | 129 | ```javascript 130 | class App extends React.Component { 131 | getChildContext() { 132 | return { shortcuts: shortcutManager } 133 | } 134 | } 135 | 136 | App.childContextTypes = { 137 | shortcuts: PropTypes.object.isRequired 138 | } 139 | ``` 140 | 141 | #### 5. Require the component 142 | 143 | You need to require the component in the file you want to use shortcuts in. 144 | For example ``. 145 | 146 | ```javascript 147 | import { Shortcuts } from `react-shortcuts` 148 | 149 | class TodoItem extends React.Component { 150 | _handleShortcuts = (action, event) => { 151 | switch (action) { 152 | case 'MOVE_LEFT': 153 | console.log('moving left') 154 | break 155 | case 'MOVE_RIGHT': 156 | console.log('moving right') 157 | break 158 | case 'MOVE_UP': 159 | console.log('moving up') 160 | break 161 | case 'COPY': 162 | console.log('copying stuff') 163 | break 164 | } 165 | } 166 | 167 | render() { 168 | return ( 169 | 173 |
Make something amazing today
174 |
175 | ) 176 | } 177 | } 178 | ``` 179 | 180 | > The `` component creates a `` element in HTML, binds 181 | listeners and adds tabIndex to the element so that it’s focusable. 182 | `_handleShortcuts` is invoked when some of the defined shortcuts fire. 183 | 184 | ## Custom props for `` component 185 | 186 | - `handler`: func 187 | - callback function that will fire when a shortcut occurs 188 | - `name`: string 189 | - The name of the namespace specified in keymap file 190 | - `tabIndex`: number 191 | - Default is `-1` 192 | - `className`: string 193 | - `eventType`: string 194 | - Just for gourmets (keyup, keydown, keypress) 195 | - `stopPropagation`: bool 196 | - `preventDefault`: bool 197 | - `targetNodeSelector`: DOM Node Selector like `body` or `.my-class` 198 | - Use this one with caution. It binds listeners to the provided string instead 199 | of the component. 200 | - `global`: bool 201 | - Use this when you have some global app wide shortcuts like `CMD+Q`. 202 | - `isolate`: bool 203 | - Use this when a child component has React's key handler (onKeyUp, onKeyPress, onKeyDown). Otherwise, React Shortcuts stops propagation of that event due to nature of event delegation that React uses internally. 204 | - `alwaysFireHandler`: bool 205 | - Use this when you want events keep firing on the focused input elements. 206 | 207 | 208 | ## Thanks, Atom 209 | 210 | 211 | This library is inspired by [Atom Keymap]. 212 | 213 | 214 | [Atom Keymap]: https://github.com/atom/atom-keymap/ 215 | [travis]: https://travis-ci.org/avocode/react-shortcuts 216 | [mousetrap]: https://craig.is/killing/mice 217 | [keymaps]: https://github.com/atom/atom-keymap/ 218 | -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import createClass from 'create-react-class' 4 | import ReactDOMFactories from 'react-dom-factories' 5 | 6 | let { Shortcuts } = require('../src') 7 | 8 | Shortcuts = React.createFactory(Shortcuts) 9 | const { button, div, h1, p } = ReactDOMFactories 10 | 11 | export default createClass({ 12 | displayName: 'App', 13 | 14 | childContextTypes: { 15 | shortcuts: PropTypes.object.isRequired, 16 | }, 17 | 18 | getInitialState() { 19 | return { show: true, who: 'Nobody' } 20 | }, 21 | 22 | getChildContext() { 23 | return { shortcuts: this.props.shortcuts } 24 | }, 25 | 26 | _handleShortcuts(command) { 27 | switch (command) { 28 | case 'MOVE_LEFT': return this.setState({ who: 'Hemingway - left' }) 29 | case 'DELETE': return this.setState({ who: 'Hemingway - delete' }) 30 | case 'MOVE_RIGHT': return this.setState({ who: 'Hemingway - right' }) 31 | case 'MOVE_UP': return this.setState({ who: 'Hemingway - top' }) 32 | } 33 | }, 34 | 35 | _handleShortcuts2(command) { 36 | switch (command) { 37 | case 'MOVE_LEFT': return this.setState({ who: 'Franz Kafka - left' }) 38 | case 'DELETE': return this.setState({ who: 'Franz Kafka - delete' }) 39 | case 'MOVE_RIGHT': return this.setState({ who: 'Franz Kafka - right' }) 40 | case 'MOVE_UP': return this.setState({ who: 'Franz Kafka - top' }) 41 | } 42 | }, 43 | 44 | _handleRoot(command) { 45 | this.setState({ who: 'Root shortcuts component' }) 46 | }, 47 | 48 | _rebind() { 49 | this.setState({ show: false }) 50 | 51 | setTimeout(() => { 52 | this.setState({ show: true }) 53 | }, 100) 54 | }, 55 | 56 | render() { 57 | if (!this.state.show) { 58 | return null 59 | } 60 | 61 | return ( 62 | 63 | div({ className: 'root' }, 64 | 65 | h1({ className: 'who' }, this.state.who), 66 | button({ className: 'rebind', onClick: this._rebind }, 'Rebind listeners'), 67 | 68 | Shortcuts({ 69 | name: this.constructor.displayName, 70 | handler: this._handleShortcuts, 71 | targetNodeSelector: '#app', 72 | className: 'content', 73 | }, 74 | div(null, 75 | h1(null, 'Hemingway'), 76 | p(null, 'Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary regelialia.') 77 | ) 78 | ), 79 | 80 | Shortcuts({ 81 | name: this.constructor.displayName, 82 | handler: this._handleShortcuts2, 83 | stopPropagation: true, 84 | className: 'content', 85 | }, 86 | 87 | div(null, 88 | h1(null, 'Franz Kafka'), 89 | p(null, 'One morning, when Gregor Samsa woke from troubled dreams, he found himself transformed in his bed into a horrible vermin. He lay on his armour-like back, and if he lifted his head a little he could see his brown belly, slightly domed and divided by arches into stiff sections.') 90 | ) 91 | ) 92 | ) 93 | 94 | ) 95 | }, 96 | }) 97 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /example/keymap.js: -------------------------------------------------------------------------------- 1 | export default { 2 | App: { 3 | MOVE_LEFT: 'left', 4 | MOVE_RIGHT: 'right', 5 | MOVE_UP: ['up', 'w'], 6 | DELETE: { 7 | osx: ['command+backspace', 'k'], 8 | windows: 'delete', 9 | linux: 'delete', 10 | }, 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './main.less' 4 | import keymap from './keymap' 5 | import App from './app' 6 | import { ShortcutManager } from '../src' 7 | 8 | const shortcutManager = new ShortcutManager(keymap) 9 | 10 | // Just for testing 11 | window.shortcutManager = shortcutManager 12 | 13 | const element = React.createElement(App, { shortcuts: shortcutManager }) 14 | ReactDOM.render(element, document.getElementById('app')) 15 | -------------------------------------------------------------------------------- /example/main.less: -------------------------------------------------------------------------------- 1 | html { 2 | color: #fff; 3 | background: #222; 4 | line-height: 1.5; 5 | } 6 | 7 | .root { 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | flex-direction: column; 12 | } 13 | 14 | .who { 15 | text-align: center; 16 | } 17 | 18 | .content { 19 | width: 400px; 20 | margin: 15px auto; 21 | background: #535394; 22 | padding: 20px; 23 | display: flex; 24 | } 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-shortcuts", 3 | "description": "React shortcuts", 4 | "version": "2.1.0", 5 | "license": "MIT", 6 | "main": "./lib/", 7 | "maintainers": [ 8 | { 9 | "name": "Petr Brzek", 10 | "email": "petr@avocode.com" 11 | } 12 | ], 13 | "keywords": [ 14 | "react", 15 | "react-component", 16 | "keyboard", 17 | "shortcuts", 18 | "mousetrap" 19 | ], 20 | "scripts": { 21 | "prepublish": "babel src/ -d lib/", 22 | "start": "webpack-dev-server --hot --progress --colors", 23 | "test": "mocha" 24 | }, 25 | "dependencies": { 26 | "combokeys": "^3.0.1", 27 | "events": "^1.0.2", 28 | "invariant": "^2.1.0", 29 | "just-reduce-object": "^1.0.3", 30 | "platform": "^1.3.0", 31 | "prop-types": "^15.5.8" 32 | }, 33 | "peerDependencies": { 34 | "react": "^0.14.8 || ^15 || ^16", 35 | "react-dom": "^0.14.8 || ^15 || ^16" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git://github.com/avocode/react-shortcuts.git", 40 | "web": "http://github.com/avocode/react-shortcuts" 41 | }, 42 | "bugs": { 43 | "url": "http://github.com/avocode/react-shortcuts/issues" 44 | }, 45 | "devDependencies": { 46 | "babel-cli": "^6.14.0", 47 | "babel-core": "^6.14.0", 48 | "babel-eslint": "^7.1.1", 49 | "babel-loader": "^6.2.5", 50 | "babel-plugin-add-module-exports": "^0.2.1", 51 | "babel-polyfill": "^6.13.0", 52 | "babel-preset-es2015": "^6.14.0", 53 | "babel-preset-react": "^6.11.1", 54 | "babel-preset-react-optimize": "^1.0.1", 55 | "babel-preset-stage-0": "^6.5.0", 56 | "chai": "^3.5.0", 57 | "chai-enzyme": "^1.0.0-beta.1", 58 | "cheerio": "^0.20.0", 59 | "create-react-class": "^15.6.3", 60 | "css-loader": "^0.15.6", 61 | "enzyme": "^3.0.0", 62 | "enzyme-adapter-react-16": "^1.15.1", 63 | "eslint": "^3.10.2", 64 | "eslint-config-airbnb": "^13.0.0", 65 | "eslint-import-resolver-webpack": "^0.7.0", 66 | "eslint-plugin-import": "^2.2.0", 67 | "eslint-plugin-jsx-a11y": "^2.2.3", 68 | "eslint-plugin-react": "^6.7.1", 69 | "eslint-plugin-standard": "^2.0.1", 70 | "istanbul": "^0.3.18", 71 | "jsdom": "^8.0.4", 72 | "less": "^2.5.1", 73 | "less-loader": "^2.2.0", 74 | "lodash": "^4.15.0", 75 | "mocha": "^2.2.5", 76 | "react": "^16", 77 | "react-dom": "^16", 78 | "react-dom-factories": "^1.0.2", 79 | "simulant": "^0.2.2", 80 | "sinon": "^1.17.5", 81 | "sinon-chai": "^2.8.0", 82 | "style-loader": "^0.12.3", 83 | "webpack": "^1.11.0", 84 | "webpack-dev-server": "^1.10.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/component/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./shortcuts') 2 | -------------------------------------------------------------------------------- /src/component/shortcuts.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import invariant from 'invariant' 3 | import Combokeys from 'combokeys' 4 | import PropTypes from 'prop-types' 5 | 6 | import helpers from '../helpers' 7 | 8 | export default class extends React.Component { 9 | static displayName = 'Shortcuts'; 10 | 11 | static contextTypes = { 12 | shortcuts: PropTypes.object.isRequired, 13 | }; 14 | 15 | static propTypes = { 16 | children: PropTypes.node, 17 | handler: PropTypes.func, 18 | name: PropTypes.string, 19 | tabIndex: PropTypes.number, 20 | className: PropTypes.string, 21 | eventType: PropTypes.string, 22 | stopPropagation: PropTypes.bool, 23 | preventDefault: PropTypes.bool, 24 | targetNodeSelector: PropTypes.string, 25 | global: PropTypes.bool, 26 | isolate: PropTypes.bool, 27 | alwaysFireHandler: PropTypes.bool, 28 | }; 29 | 30 | static defaultProps = { 31 | tabIndex: -1, 32 | className: null, 33 | eventType: null, 34 | stopPropagation: true, 35 | preventDefault: false, 36 | targetNodeSelector: null, 37 | global: false, 38 | isolate: false, 39 | alwaysFireHandler: false, 40 | }; 41 | 42 | componentDidMount() { 43 | this._onUpdate() 44 | 45 | if (this.props.name) { 46 | this.context.shortcuts.addUpdateListener(this._onUpdate) 47 | } 48 | } 49 | 50 | componentWillUnmount() { 51 | this._unbindShortcuts() 52 | 53 | if (this.props.name) { 54 | this.context.shortcuts.removeUpdateListener(this._onUpdate) 55 | } 56 | 57 | if (this.props.global) { 58 | const element = this._getElementToBind() 59 | element.removeEventListener( 60 | 'shortcuts:global', 61 | this._customGlobalHandler 62 | ) 63 | } 64 | } 65 | 66 | // NOTE: combokeys must be instance per component 67 | _combokeys = null; 68 | 69 | _lastEvent = null; 70 | 71 | _bindShortcuts = (shortcutsArr) => { 72 | const element = this._getElementToBind() 73 | element.setAttribute('tabindex', this.props.tabIndex) 74 | this._combokeys = new Combokeys(element, { storeInstancesGlobally: false }) 75 | this._decorateCombokeys() 76 | this._combokeys.bind( 77 | shortcutsArr, 78 | this._handleShortcuts, 79 | this.props.eventType 80 | ) 81 | 82 | if (this.props.global) { 83 | element.addEventListener('shortcuts:global', this._customGlobalHandler) 84 | } 85 | }; 86 | 87 | _customGlobalHandler = (e) => { 88 | const { character, modifiers, event } = e.detail 89 | 90 | let targetNode = null 91 | if (this.props.targetNodeSelector) { 92 | targetNode = document.querySelector(this.props.targetNodeSelector) 93 | } 94 | 95 | if (e.target !== this._domNode && e.target !== targetNode) { 96 | this._combokeys.handleKey(character, modifiers, event, true) 97 | } 98 | }; 99 | 100 | _decorateCombokeys = () => { 101 | const element = this._getElementToBind() 102 | const originalHandleKey = this._combokeys.handleKey.bind(this._combokeys) 103 | 104 | // NOTE: stopCallback is a method that is called to see 105 | // if the keyboard event should fire 106 | this._combokeys.stopCallback = (event, domElement, combo) => { 107 | const isInputLikeElement = domElement.tagName === 'INPUT' || 108 | domElement.tagName === 'SELECT' || 109 | domElement.tagName === 'TEXTAREA' || 110 | (domElement.contentEditable && domElement.contentEditable === 'true') 111 | 112 | let isReturnString 113 | if (event.key) { 114 | isReturnString = event.key.length === 1 115 | } else { 116 | isReturnString = Boolean(helpers.getCharacter(event)) 117 | } 118 | 119 | if ( 120 | isInputLikeElement && isReturnString && !this.props.alwaysFireHandler 121 | ) { 122 | return true 123 | } 124 | 125 | return false 126 | } 127 | 128 | this._combokeys.handleKey = ( 129 | character, 130 | modifiers, 131 | event, 132 | isGlobalHandler 133 | ) => { 134 | if ( 135 | this._lastEvent && 136 | event.timeStamp === this._lastEvent.timeStamp && 137 | event.type === this._lastEvent.type 138 | ) { 139 | return 140 | } 141 | this._lastEvent = event 142 | 143 | let isolateOwner = false 144 | if (this.props.isolate && !event.__isolateShortcuts) { 145 | event.__isolateShortcuts = true 146 | isolateOwner = true 147 | } 148 | 149 | if (!isGlobalHandler) { 150 | element.dispatchEvent( 151 | new CustomEvent('shortcuts:global', { 152 | detail: { character, modifiers, event }, 153 | bubbles: true, 154 | cancelable: true, 155 | }) 156 | ) 157 | } 158 | 159 | // NOTE: works normally if it's not an isolated event 160 | if (!event.__isolateShortcuts) { 161 | if (this.props.preventDefault) { 162 | event.preventDefault() 163 | } 164 | if (this.props.stopPropagation && !isGlobalHandler) { 165 | event.stopPropagation() 166 | } 167 | originalHandleKey(character, modifiers, event) 168 | return 169 | } 170 | 171 | // NOTE: global shortcuts should work even for an isolated event 172 | if (this.props.global || isolateOwner) { 173 | originalHandleKey(character, modifiers, event) 174 | } 175 | } 176 | }; 177 | 178 | _getElementToBind = () => { 179 | let element = null 180 | if (this.props.targetNodeSelector) { 181 | element = document.querySelector(this.props.targetNodeSelector) 182 | invariant( 183 | element, 184 | `Node selector '${this.props.targetNodeSelector}' was not found.` 185 | ) 186 | } else { 187 | element = this._domNode 188 | } 189 | 190 | return element 191 | }; 192 | 193 | _unbindShortcuts = () => { 194 | if (this._combokeys) { 195 | this._combokeys.detach() 196 | this._combokeys.reset() 197 | } 198 | }; 199 | 200 | _onUpdate = () => { 201 | const shortcutsArr = this.props.name && 202 | this.context.shortcuts.getShortcuts(this.props.name) 203 | this._unbindShortcuts() 204 | this._bindShortcuts(shortcutsArr || []) 205 | }; 206 | 207 | _handleShortcuts = (event, keyName) => { 208 | if (this.props.name) { 209 | const shortcutName = this.context.shortcuts.findShortcutName( 210 | keyName, 211 | this.props.name 212 | ) 213 | 214 | if (this.props.handler) { 215 | this.props.handler(shortcutName, event) 216 | } 217 | } 218 | }; 219 | 220 | render() { 221 | return ( 222 |
{ 224 | this._domNode = node 225 | }} 226 | tabIndex={this.props.tabIndex} 227 | className={this.props.className} 228 | > 229 | {this.props.children} 230 |
231 | ) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/helpers.js: -------------------------------------------------------------------------------- 1 | import platform from 'platform' 2 | 3 | const getPlatformName = () => { 4 | let os = platform.os.family || '' 5 | os = os.toLowerCase().replace(/ /g, '') 6 | if (/\bwin/.test(os)) { 7 | os = 'windows' 8 | } else if (/darwin|osx/.test(os)) { 9 | os = 'osx' 10 | } else if (/linux|freebsd|sunos|ubuntu|debian|fedora|redhat|suse/.test(os)) { 11 | os = 'linux' 12 | } else { 13 | os = 'other' 14 | } 15 | return os 16 | } 17 | 18 | const getCharacter = (event) => { 19 | if (event.which == null) { 20 | // NOTE: IE 21 | return String.fromCharCode(event.keyCode) 22 | } else if (event.which !== 0 && event.charCode !== 0) { 23 | // NOTE: the rest 24 | return String.fromCharCode(event.which) 25 | } 26 | return null 27 | } 28 | 29 | export default { getPlatformName, getCharacter } 30 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ShortcutManager: require('./shortcut-manager'), 3 | Shortcuts: require('./component/'), 4 | } 5 | -------------------------------------------------------------------------------- /src/shortcut-manager.js: -------------------------------------------------------------------------------- 1 | import reduce from 'just-reduce-object' 2 | import invariant from 'invariant' 3 | import { EventEmitter } from 'events' 4 | import helpers from './helpers' 5 | import { isPlainObject, findKey, isArray, map, compact, flatten } from './utils' 6 | 7 | 8 | const warning = (text) => { 9 | if (process && process.env.NODE_ENV !== 'production') { 10 | console.warn(text) 11 | } 12 | } 13 | 14 | class ShortcutManager extends EventEmitter { 15 | static CHANGE_EVENT = 'shortcuts:update' 16 | 17 | constructor(keymap = {}) { 18 | super() 19 | this._keymap = keymap 20 | } 21 | 22 | addUpdateListener(callback) { 23 | invariant(callback, 24 | 'addUpdateListener: callback argument is not defined or falsy') 25 | this.on(ShortcutManager.CHANGE_EVENT, callback) 26 | } 27 | 28 | removeUpdateListener(callback) { 29 | this.removeListener(ShortcutManager.CHANGE_EVENT, callback) 30 | } 31 | 32 | _platformName = helpers.getPlatformName() 33 | 34 | _parseShortcutDescriptor = (item) => { 35 | if (isPlainObject(item)) { 36 | return item[this._platformName] 37 | } 38 | return item 39 | } 40 | 41 | setKeymap(keymap) { 42 | invariant(keymap, 43 | 'setKeymap: keymap argument is not defined or falsy.') 44 | this._keymap = keymap 45 | this.emit(ShortcutManager.CHANGE_EVENT) 46 | } 47 | 48 | extendKeymap(keymap) { 49 | invariant(keymap, 50 | 'extendKeymap: keymap argument is not defined or falsy.') 51 | this._keymap = Object.assign({}, this._keymap, keymap) 52 | this.emit(ShortcutManager.CHANGE_EVENT) 53 | } 54 | 55 | getAllShortcuts() { 56 | return this._keymap 57 | } 58 | 59 | getAllShortcutsForPlatform(platformName) { 60 | const _transformShortcuts = (shortcuts) => { 61 | return reduce(shortcuts, (result, keyName, keyValue) => { 62 | if (isPlainObject(keyValue)) { 63 | if (keyValue[platformName]) { 64 | keyValue = keyValue[platformName] 65 | } else { 66 | result[keyName] = _transformShortcuts(keyValue) 67 | return result 68 | } 69 | } 70 | 71 | result[keyName] = keyValue 72 | return result 73 | }, {}) 74 | } 75 | 76 | return _transformShortcuts(this._keymap) 77 | } 78 | 79 | getAllShortcutsForCurrentPlatform() { 80 | return this.getAllShortcutsForPlatform(this._platformName) 81 | } 82 | 83 | getShortcuts(componentName) { 84 | invariant(componentName, 85 | 'getShortcuts: name argument is not defined or falsy.') 86 | 87 | const cursor = this._keymap[componentName] 88 | if (!cursor) { 89 | warning(`getShortcuts: There are no shortcuts with name ${componentName}.`) 90 | return 91 | } 92 | 93 | const shortcuts = compact(flatten(map(cursor, this._parseShortcutDescriptor))) 94 | 95 | return shortcuts 96 | } 97 | 98 | _parseShortcutKeyName(obj, keyName) { 99 | const result = findKey(obj, (item) => { 100 | if (isPlainObject(item)) { 101 | item = item[this._platformName] 102 | } 103 | if (isArray(item)) { 104 | const index = item.indexOf(keyName) 105 | if (index >= 0) { item = item[index] } 106 | } 107 | return item === keyName 108 | }) 109 | 110 | return result 111 | } 112 | 113 | findShortcutName(keyName, componentName) { 114 | invariant(keyName, 115 | 'findShortcutName: keyName argument is not defined or falsy.') 116 | invariant(componentName, 117 | 'findShortcutName: componentName argument is not defined or falsy.') 118 | 119 | const cursor = this._keymap[componentName] 120 | const result = this._parseShortcutKeyName(cursor, keyName) 121 | 122 | return result 123 | } 124 | } 125 | 126 | 127 | export default ShortcutManager 128 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isArray = arr => Array.isArray(arr) 2 | 3 | export const isPlainObject = (obj) => { 4 | const isObject = typeof obj === 'object' && obj !== null && !isArray(obj) 5 | if (!isObject || (obj.toString && obj.toString() !== '[object Object]')) return false 6 | const proto = Object.getPrototypeOf(obj) 7 | if (proto === null) { 8 | return true 9 | } 10 | const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor 11 | return typeof Ctor === 'function' && Ctor instanceof Ctor && 12 | Function.prototype.toString.call(Ctor) === Function.prototype.toString.call(Object) 13 | } 14 | 15 | export const findKey = (obj, fn) => { 16 | if (!isPlainObject(obj) && !isArray(obj)) return 17 | 18 | const keys = Object.keys(obj) 19 | return keys.find(key => fn(obj[key])) 20 | } 21 | 22 | export const compact = arr => arr.filter(Boolean) 23 | 24 | const flattenOnce = (arr, recurse = true) => { 25 | return arr.reduce((acc, val) => { 26 | if (isArray(val) && recurse) return acc.concat(flattenOnce(val, false)) 27 | acc.push(val) 28 | return acc 29 | }, []) 30 | } 31 | 32 | export const flatten = (arr) => { 33 | if (!isArray(arr)) throw new Error('flatten expects an array') 34 | return flattenOnce(arr) 35 | } 36 | 37 | export const map = (itr, fn) => { 38 | if (isArray(itr)) return itr.map(fn) 39 | 40 | const results = [] 41 | const keys = Object.keys(itr) 42 | const len = keys.length 43 | for (let i = 0; i < len; i += 1) { 44 | const key = keys[i] 45 | results.push(fn(itr[key], key)) 46 | } 47 | 48 | return results 49 | } 50 | -------------------------------------------------------------------------------- /test/keymap.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 'Test': { 3 | MOVE_LEFT: 'left', 4 | MOVE_RIGHT: 'right', 5 | MOVE_UP: ['up', 'w'], 6 | DELETE: { 7 | osx: 'alt+backspace', 8 | windows: 'delete', 9 | linux: 'alt+backspace', 10 | other: 'alt+backspace', 11 | }, 12 | }, 13 | 'Next': { 14 | OPEN: 'alt+o', 15 | ABORT: ['d', 'c'], 16 | CLOSE: { 17 | osx: ['esc', 'enter'], 18 | windows: ['esc', 'enter'], 19 | linux: ['esc', 'enter'], 20 | other: ['esc', 'enter'], 21 | }, 22 | }, 23 | 'TESTING': { 24 | 'OPEN': 'enter', 25 | 'CLOSE': 'esc', 26 | }, 27 | 'NON-EXISTING': {}, 28 | } 29 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --compilers js:babel-core/register 2 | --recursive 3 | -------------------------------------------------------------------------------- /test/shortcut-manager.spec.js: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom' 2 | import chai from 'chai' 3 | import _ from 'lodash' 4 | import sinonChai from 'sinon-chai' 5 | import sinon from 'sinon' 6 | 7 | import keymap from './keymap' 8 | 9 | chai.use(sinonChai) 10 | 11 | const { expect } = chai 12 | 13 | describe('Shortcut manager', () => { 14 | let ShortcutManager = null 15 | 16 | before(() => { 17 | global.document = jsdom.jsdom('') 18 | global.window = document.defaultView 19 | global.Image = window.Image 20 | global.navigator = window.navigator 21 | global.CustomEvent = window.CustomEvent 22 | 23 | ShortcutManager = require('../src').ShortcutManager 24 | }) 25 | 26 | it('should return empty object when calling empty constructor', () => { 27 | const manager = new ShortcutManager() 28 | expect(manager.getAllShortcuts()).to.be.empty 29 | }) 30 | 31 | it('should return all shortcuts', () => { 32 | const manager = new ShortcutManager(keymap) 33 | expect(manager.getAllShortcuts()).to.not.be.empty 34 | expect(manager.getAllShortcuts()).to.be.equal(keymap) 35 | 36 | manager.setKeymap({}) 37 | expect(manager.getAllShortcuts()).to.be.empty 38 | 39 | manager.setKeymap(keymap) 40 | expect(manager.getAllShortcuts()).to.be.equal(keymap) 41 | }) 42 | 43 | it('should return all shortcuts for the Windows platform', () => { 44 | const manager = new ShortcutManager(keymap) 45 | const keyMapResult = { 46 | 'Test': { 47 | MOVE_LEFT: 'left', 48 | MOVE_RIGHT: 'right', 49 | MOVE_UP: ['up', 'w'], 50 | DELETE: 'delete', 51 | }, 52 | 'Next': { 53 | OPEN: 'alt+o', 54 | ABORT: ['d', 'c'], 55 | CLOSE: ['esc', 'enter'], 56 | }, 57 | 'TESTING': { 58 | 'OPEN': 'enter', 59 | 'CLOSE': 'esc', 60 | }, 61 | 'NON-EXISTING': {}, 62 | } 63 | 64 | expect(manager.getAllShortcutsForPlatform('windows')).to.eql(keyMapResult) 65 | }) 66 | 67 | it('should return all shortcuts for the macOs platform', () => { 68 | const manager = new ShortcutManager(keymap) 69 | const keyMapResult = { 70 | 'Test': { 71 | MOVE_LEFT: 'left', 72 | MOVE_RIGHT: 'right', 73 | MOVE_UP: ['up', 'w'], 74 | DELETE: 'alt+backspace', 75 | }, 76 | 'Next': { 77 | OPEN: 'alt+o', 78 | ABORT: ['d', 'c'], 79 | CLOSE: ['esc', 'enter'], 80 | }, 81 | 'TESTING': { 82 | 'OPEN': 'enter', 83 | 'CLOSE': 'esc', 84 | }, 85 | 'NON-EXISTING': {}, 86 | } 87 | 88 | expect(manager.getAllShortcutsForPlatform('osx')).to.eql(keyMapResult) 89 | }) 90 | 91 | it('should expose the change event type as a static constant', () => 92 | expect(ShortcutManager.CHANGE_EVENT).to.exist 93 | ) 94 | 95 | it('should have static CHANGE_EVENT', () => 96 | expect(ShortcutManager.CHANGE_EVENT).to.be.equal('shortcuts:update') 97 | ) 98 | 99 | it('should call onUpdate', () => { 100 | const manager = new ShortcutManager() 101 | const spy = sinon.spy() 102 | manager.addUpdateListener(spy) 103 | manager.setKeymap({}) 104 | expect(spy).to.have.beenCalled 105 | }) 106 | 107 | it('should throw an error when setKeymap is called without arg', () => { 108 | const manager = new ShortcutManager(keymap) 109 | const error = /setKeymap: keymap argument is not defined or falsy./ 110 | expect(manager.setKeymap).to.throw(error) 111 | }) 112 | 113 | it('should extend the keymap', () => { 114 | const manager = new ShortcutManager() 115 | const newKeymap = { 'TESTING-NAMESPACE': {} } 116 | const extendedKeymap = Object.assign({}, keymap, newKeymap) 117 | manager.setKeymap(keymap) 118 | manager.extendKeymap(newKeymap) 119 | 120 | expect(manager.getAllShortcuts()).to.eql(extendedKeymap) 121 | }) 122 | 123 | it('should return array of shortcuts', () => { 124 | const manager = new ShortcutManager(keymap) 125 | let shortcuts = manager.getShortcuts('Test') 126 | expect(shortcuts).to.be.an.array 127 | 128 | let shouldContainStrings = _.every(shortcuts, _.isString) 129 | expect(shouldContainStrings).to.be.equal(true) 130 | expect(shortcuts.length).to.be.equal(5) 131 | 132 | shortcuts = manager.getShortcuts('Next') 133 | expect(shortcuts).to.be.an.array 134 | shouldContainStrings = _.every(shortcuts, _.isString) 135 | expect(shouldContainStrings).to.be.equal(true) 136 | expect(shortcuts.length).to.be.equal(5) 137 | }) 138 | 139 | it('should not throw an error when getting not existing key from keymap', () => { 140 | const manager = new ShortcutManager(keymap) 141 | const notExist = () => manager.getShortcuts('NotExist') 142 | expect(notExist).to.not.throw() 143 | }) 144 | 145 | it('should return correct key label', () => { 146 | const manager = new ShortcutManager() 147 | manager.setKeymap(keymap) 148 | 149 | // Test 150 | expect(manager.findShortcutName('alt+backspace', 'Test')).to.be.equal('DELETE') 151 | expect(manager.findShortcutName('w', 'Test')).to.be.equal('MOVE_UP') 152 | expect(manager.findShortcutName('up', 'Test')).to.be.equal('MOVE_UP') 153 | expect(manager.findShortcutName('left', 'Test')).to.be.equal('MOVE_LEFT') 154 | expect(manager.findShortcutName('right', 'Test')).to.be.equal('MOVE_RIGHT') 155 | 156 | // Next 157 | expect(manager.findShortcutName('alt+o', 'Next')).to.be.equal('OPEN') 158 | expect(manager.findShortcutName('d', 'Next')).to.be.equal('ABORT') 159 | expect(manager.findShortcutName('c', 'Next')).to.be.equal('ABORT') 160 | expect(manager.findShortcutName('esc', 'Next')).to.be.equal('CLOSE') 161 | expect(manager.findShortcutName('enter', 'Next')).to.be.equal('CLOSE') 162 | }) 163 | 164 | it('should throw an error', () => { 165 | const manager = new ShortcutManager() 166 | const fn = () => manager.findShortcutName('left') 167 | expect(manager.findShortcutName).to.throw(/findShortcutName: keyName argument is not defined or falsy./) 168 | expect(fn).to.throw(/findShortcutName: componentName argument is not defined or falsy./) 169 | }) 170 | }) 171 | -------------------------------------------------------------------------------- /test/shortcuts.spec.js: -------------------------------------------------------------------------------- 1 | import ReactDOMFactories from 'react-dom-factories' 2 | import jsdom from 'jsdom' 3 | import chai from 'chai' 4 | import sinonChai from 'sinon-chai' 5 | import sinon from 'sinon' 6 | import _ from 'lodash' 7 | import enzyme from 'enzyme' 8 | import Adapter from 'enzyme-adapter-react-16' 9 | import keymap from './keymap' 10 | 11 | enzyme.configure({ adapter: new Adapter() }) 12 | 13 | describe('Shortcuts component', () => { 14 | let baseProps = null 15 | let baseContext = null 16 | 17 | let simulant = null 18 | let ShortcutManager = null 19 | let Shortcuts = null 20 | let ReactDOM = null 21 | let React = null 22 | 23 | chai.use(sinonChai) 24 | const { expect } = chai 25 | 26 | beforeEach(() => { 27 | global.document = jsdom.jsdom('') 28 | global.window = document.defaultView 29 | global.Image = window.Image 30 | global.navigator = window.navigator 31 | global.CustomEvent = window.CustomEvent 32 | simulant = require('simulant') 33 | ReactDOM = require('react-dom') 34 | React = require('react') 35 | const chaiEnzyme = require('chai-enzyme') 36 | 37 | chai.use(chaiEnzyme()) 38 | 39 | ShortcutManager = require('../src').ShortcutManager 40 | const shortcutsManager = new ShortcutManager(keymap) 41 | 42 | Shortcuts = require('../src/').Shortcuts 43 | 44 | baseProps = { 45 | handler: sinon.spy(), 46 | name: 'TESTING', 47 | className: null, 48 | } 49 | baseContext = { shortcuts: shortcutsManager } 50 | }) 51 | 52 | it('should render component', () => { 53 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 54 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 55 | 56 | expect(wrapper.find('div')).to.have.length(1) 57 | }) 58 | 59 | it('should have a tabIndex of -1 by default', () => { 60 | let shortcutComponent = React.createElement(Shortcuts, baseProps) 61 | let wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 62 | 63 | expect(wrapper.props().tabIndex).to.be.equal(-1) 64 | 65 | const props = _.assign({}, baseProps, { tabIndex: 42 }) 66 | shortcutComponent = React.createElement(Shortcuts, props) 67 | wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 68 | 69 | expect(wrapper.props().tabIndex).to.be.equal(props.tabIndex) 70 | let realTabIndex = ReactDOM.findDOMNode(wrapper.instance()).getAttribute('tabindex') 71 | expect(realTabIndex).to.have.equal(String(props.tabIndex)) 72 | 73 | props.tabIndex = 0 74 | shortcutComponent = React.createElement(Shortcuts, props) 75 | wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 76 | 77 | expect(wrapper.props().tabIndex).to.be.equal(props.tabIndex) 78 | realTabIndex = ReactDOM.findDOMNode(wrapper.instance()).getAttribute('tabindex') 79 | expect(realTabIndex).to.have.equal(String(props.tabIndex)) 80 | }) 81 | 82 | it('should not have className by default', () => { 83 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 84 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 85 | 86 | expect(wrapper.props().className).to.be.equal(null) 87 | }) 88 | 89 | it('should have className', () => { 90 | const props = _.assign({}, baseProps, { className: 'testing' }) 91 | const shortcutComponent = React.createElement(Shortcuts, props) 92 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 93 | 94 | expect(wrapper.props().className).to.be.equal('testing') 95 | expect(wrapper).to.have.className('testing') 96 | }) 97 | 98 | it('should have isolate prop set to false by default', () => { 99 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 100 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 101 | 102 | expect(wrapper.props().isolate).to.be.equal(false) 103 | }) 104 | 105 | it('should NOT store combokeys instances on Combokeys constructor', () => { 106 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 107 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 108 | 109 | expect(wrapper.find('Shortcuts').instance()._combokeys.constructor.instances).to.be.empty 110 | }) 111 | 112 | it('should have isolate prop', () => { 113 | const props = _.assign({}, baseProps, { isolate: true }) 114 | const shortcutComponent = React.createElement(Shortcuts, props) 115 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 116 | 117 | expect(wrapper.props().isolate).to.be.equal(true) 118 | }) 119 | 120 | it('should not have children by default', () => { 121 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 122 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 123 | 124 | expect(wrapper.props().children).to.be.equal(undefined) 125 | }) 126 | 127 | it('should have children', () => { 128 | const props = _.assign({}, baseProps, { children: ReactDOMFactories.div() }) 129 | const shortcutComponent = React.createElement(Shortcuts, props) 130 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 131 | 132 | expect(wrapper).to.contain(ReactDOMFactories.div()) 133 | }) 134 | 135 | it('should have handler prop', () => { 136 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 137 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 138 | 139 | expect(wrapper.props().handler).to.be.function 140 | }) 141 | 142 | it('should have name prop', () => { 143 | const props = _.assign({}, baseProps, 144 | { name: 'TESTING' }) 145 | const shortcutComponent = React.createElement(Shortcuts, props) 146 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 147 | 148 | expect(wrapper.props().name).to.be.equal('TESTING') 149 | }) 150 | 151 | it('should not have eventType prop by default', () => { 152 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 153 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 154 | 155 | expect(wrapper.props().eventType).to.be.equal(null) 156 | }) 157 | 158 | it('should have eventType prop', () => { 159 | const props = _.assign({}, baseProps, { eventType: 'keyUp' }) 160 | const shortcutComponent = React.createElement(Shortcuts, props) 161 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 162 | 163 | expect(wrapper.props().eventType).to.be.equal('keyUp') 164 | }) 165 | 166 | it('should have stopPropagation prop by default', () => { 167 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 168 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 169 | 170 | expect(wrapper.props().stopPropagation).to.be.equal(true) 171 | }) 172 | 173 | it('should have stopPropagation prop set to false', () => { 174 | const props = _.assign({}, baseProps, { stopPropagation: false }) 175 | const shortcutComponent = React.createElement(Shortcuts, props) 176 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 177 | 178 | expect(wrapper.props().stopPropagation).to.be.equal(false) 179 | }) 180 | 181 | it('should have preventDefault prop set to false by default', () => { 182 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 183 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 184 | 185 | expect(wrapper.props().preventDefault).to.be.equal(false) 186 | }) 187 | 188 | it('should have preventDefault prop set to true', () => { 189 | const props = _.assign({}, baseProps, { preventDefault: true }) 190 | const shortcutComponent = React.createElement(Shortcuts, props) 191 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 192 | 193 | expect(wrapper.props().preventDefault).to.be.equal(true) 194 | }) 195 | 196 | it('should not have targetNodeSelector prop by default', () => { 197 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 198 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 199 | 200 | expect(wrapper.props().targetNodeSelector).to.be.equal(null) 201 | }) 202 | 203 | it('should have targetNode prop', () => { 204 | const props = _.assign({}, baseProps, { targetNodeSelector: 'body' }) 205 | const shortcutComponent = React.createElement(Shortcuts, props) 206 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 207 | 208 | expect(wrapper.props().targetNodeSelector).to.be.equal('body') 209 | }) 210 | 211 | it('should have global prop set to false by default', () => { 212 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 213 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 214 | 215 | expect(wrapper.props().global).to.be.equal(false) 216 | }) 217 | 218 | it('should have global prop set to true', () => { 219 | const props = _.assign({}, baseProps, { global: true }) 220 | const shortcutComponent = React.createElement(Shortcuts, props) 221 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 222 | 223 | expect(wrapper.props().global).to.be.equal(true) 224 | }) 225 | 226 | it('should fire the handler prop with the correct argument', () => { 227 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 228 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 229 | 230 | const node = ReactDOM.findDOMNode(wrapper.instance()) 231 | node.focus() 232 | 233 | const enter = 13 234 | simulant.fire(node, 'keydown', { keyCode: enter }) 235 | 236 | expect(wrapper.props().handler).to.have.been.calledWith('OPEN') 237 | 238 | const esc = 27 239 | simulant.fire(node, 'keydown', { keyCode: esc }) 240 | 241 | expect(wrapper.props().handler).to.have.been.calledWith('CLOSE') 242 | }) 243 | 244 | it('should not fire the handler', () => { 245 | const props = _.assign({}, baseProps, { name: 'NON-EXISTING' }) 246 | const shortcutComponent = React.createElement(Shortcuts, props) 247 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 248 | 249 | const node = ReactDOM.findDOMNode(wrapper.instance()) 250 | node.focus() 251 | 252 | const enter = 13 253 | simulant.fire(node, 'keydown', { keyCode: enter }) 254 | 255 | expect(wrapper.props().handler).to.not.have.been.called 256 | }) 257 | 258 | it('should not fire twice when global prop is truthy', () => { 259 | const props = _.assign({}, baseProps, { global: true }) 260 | const shortcutComponent = React.createElement(Shortcuts, props) 261 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 262 | 263 | const node = ReactDOM.findDOMNode(wrapper.instance()) 264 | node.focus() 265 | 266 | const enter = 13 267 | simulant.fire(node, 'keydown', { keyCode: enter }) 268 | 269 | expect(wrapper.props().handler).to.have.been.calledOnce 270 | }) 271 | 272 | it('should not fire when the component has been unmounted', () => { 273 | const handler = sinon.spy() 274 | const shortcutComponent = React.createElement(Shortcuts, { ...baseProps, handler }) 275 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 276 | 277 | const node = ReactDOM.findDOMNode(wrapper.instance()) 278 | node.focus() 279 | 280 | wrapper.unmount() 281 | 282 | const enter = 13 283 | simulant.fire(node, 'keydown', { keyCode: enter }) 284 | 285 | expect(handler).to.not.have.been.called 286 | }) 287 | 288 | it.skip('should update the shortcuts and fire the handler', () => { 289 | const shortcutComponent = React.createElement(Shortcuts, baseProps) 290 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 291 | 292 | const node = ReactDOM.findDOMNode(wrapper.instance()) 293 | node.focus() 294 | 295 | const space = 32 296 | simulant.fire(node, 'keydown', { keyCode: space }) 297 | 298 | expect(wrapper.props().handler).to.not.have.been.called 299 | 300 | const editedKeymap = _.assign({}, keymap, { 301 | 'TESTING': { 302 | 'SPACE': 'space', 303 | }, 304 | } 305 | ) 306 | baseContext.shortcuts.setKeymap(editedKeymap) 307 | 308 | simulant.fire(node, 'keydown', { keyCode: space }) 309 | 310 | expect(baseProps.handler).to.have.been.called 311 | 312 | // NOTE: rollback the previous keymap 313 | baseContext.shortcuts.setKeymap(keymap) 314 | }) 315 | 316 | it('should fire the handler from a child input', () => { 317 | const props = _.assign({}, baseProps, { 318 | children: ReactDOMFactories.input({ type: 'text', className: 'input' }), 319 | }) 320 | const shortcutComponent = React.createElement(Shortcuts, props) 321 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 322 | 323 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 324 | const node = parentNode.querySelector('.input') 325 | node.focus() 326 | 327 | const enter = 13 328 | simulant.fire(node, 'keydown', { keyCode: enter, key: 'Enter' }) 329 | 330 | expect(wrapper.props().handler).to.have.been.called 331 | }) 332 | 333 | it('should fire the handler when using targetNodeSelector', () => { 334 | const props = _.assign({}, baseProps, { targetNodeSelector: 'body' }) 335 | const shortcutComponent = React.createElement(Shortcuts, props) 336 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 337 | 338 | const enter = 13 339 | simulant.fire(document.body, 'keydown', { keyCode: enter, key: 'Enter' }) 340 | 341 | expect(wrapper.props().handler).to.have.been.called 342 | }) 343 | 344 | it('should throw and error if targetNodeSelector is not found', () => { 345 | const props = _.assign({}, baseProps, { targetNodeSelector: 'non-existing' }) 346 | const shortcutComponent = React.createElement(Shortcuts, props) 347 | 348 | try { 349 | enzyme.mount(shortcutComponent, { context: baseContext }) 350 | } catch (err) { 351 | expect(err).to.match(/Node selector 'non-existing' {2}was not found/) 352 | } 353 | }) 354 | 355 | it('should fire the handler from focused input', () => { 356 | const props = _.assign({}, baseProps, { 357 | alwaysFireHandler: true, 358 | children: ReactDOMFactories.input({ type: 'text', className: 'input' }), 359 | }) 360 | const shortcutComponent = React.createElement(Shortcuts, props) 361 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 362 | 363 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 364 | const node = parentNode.querySelector('.input') 365 | node.focus() 366 | 367 | const enter = 13 368 | simulant.fire(node, 'keydown', { keyCode: enter }) 369 | 370 | expect(wrapper.props().handler).to.have.been.called 371 | }) 372 | 373 | 374 | describe('Shortcuts component inside Shortcuts component:', () => { 375 | it('should not fire parent handler when child handler is fired', () => { 376 | const props = _.assign({}, baseProps, { 377 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { className: 'test' })), 378 | }) 379 | const shortcutComponent = React.createElement(Shortcuts, props) 380 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 381 | 382 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 383 | const node = parentNode.querySelector('.test') 384 | 385 | node.focus() 386 | 387 | const enter = 13 388 | simulant.fire(node, 'keydown', { keyCode: enter }) 389 | 390 | expect(baseProps.handler).to.have.been.calledOnce 391 | }) 392 | 393 | it('should fire parent handler when child handler is fired', () => { 394 | const props = _.assign({}, baseProps, { 395 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { className: 'test', stopPropagation: false })), 396 | }) 397 | const shortcutComponent = React.createElement(Shortcuts, props) 398 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 399 | 400 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 401 | const node = parentNode.querySelector('.test') 402 | 403 | node.focus() 404 | 405 | const enter = 13 406 | simulant.fire(node, 'keydown', { keyCode: enter }) 407 | 408 | expect(baseProps.handler).to.have.been.calledTwice 409 | }) 410 | 411 | it('should fire parent handler when parent handler has global prop', () => { 412 | const props = _.assign({}, baseProps, { 413 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { className: 'test' })), 414 | global: true, 415 | }) 416 | 417 | const shortcutComponent = React.createElement(Shortcuts, props) 418 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 419 | 420 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 421 | const node = parentNode.querySelector('.test') 422 | 423 | node.focus() 424 | 425 | const enter = 13 426 | simulant.fire(node, 'keydown', { keyCode: enter }) 427 | 428 | expect(baseProps.handler).to.have.been.calledTwice 429 | }) 430 | 431 | it('should fire parent handler but not the child handler', () => { 432 | const props = _.assign({}, baseProps, { 433 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { name: 'NON-EXISTING', className: 'test' })), 434 | global: true, 435 | }) 436 | 437 | const shortcutComponent = React.createElement(Shortcuts, props) 438 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 439 | 440 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 441 | const node = parentNode.querySelector('.test') 442 | 443 | node.focus() 444 | 445 | const enter = 13 446 | simulant.fire(node, 'keydown', { keyCode: enter }) 447 | 448 | expect(baseProps.handler).to.have.been.calledOnce 449 | }) 450 | 451 | it('should fire for all global components', () => { 452 | const props = _.assign({}, baseProps, { 453 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { 454 | global: true, 455 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { name: 'NON-EXISTING', className: 'test' })), 456 | })), 457 | global: true, 458 | }) 459 | 460 | const shortcutComponent = React.createElement(Shortcuts, props) 461 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 462 | 463 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 464 | const node = parentNode.querySelector('.test') 465 | 466 | node.focus() 467 | 468 | const enter = 13 469 | simulant.fire(node, 'keydown', { keyCode: enter }) 470 | 471 | expect(baseProps.handler).to.have.been.calledTwice 472 | }) 473 | 474 | it('should not fire parent handler when a child has isolate prop set to true', () => { 475 | const childHandlerSpy = sinon.spy() 476 | const props = _.assign({}, baseProps, { 477 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { 478 | className: 'test', 479 | isolate: true, 480 | handler: childHandlerSpy, 481 | })), 482 | }) 483 | 484 | const shortcutComponent = React.createElement(Shortcuts, props) 485 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 486 | 487 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 488 | const node = parentNode.querySelector('.test') 489 | 490 | node.focus() 491 | 492 | const enter = 13 493 | simulant.fire(node, 'keydown', { keyCode: enter }) 494 | 495 | expect(childHandlerSpy).to.have.been.called 496 | expect(baseProps.handler).to.not.have.been.called 497 | }) 498 | 499 | it('should fire parent handler when is global and a child has isolate prop set to true', () => { 500 | const props = _.assign({}, baseProps, { 501 | global: true, 502 | children: React.createElement(Shortcuts, _.assign({}, baseProps, { className: 'test', isolate: true })), 503 | }) 504 | 505 | const shortcutComponent = React.createElement(Shortcuts, props) 506 | const wrapper = enzyme.mount(shortcutComponent, { context: baseContext }) 507 | 508 | const parentNode = ReactDOM.findDOMNode(wrapper.instance()) 509 | const node = parentNode.querySelector('.test') 510 | 511 | node.focus() 512 | 513 | const enter = 13 514 | simulant.fire(node, 'keydown', { keyCode: enter }) 515 | 516 | expect(baseProps.handler).to.have.been.called 517 | }) 518 | }) 519 | }) 520 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import _ from 'lodash' 3 | import { isArray, isPlainObject, findKey, compact, flatten, map } from '../src/utils' 4 | 5 | describe('utils', () => { 6 | const { expect } = chai 7 | let primitives 8 | 9 | beforeEach(() => { 10 | function fn() { this.a = 1 } 11 | 12 | primitives = [ 13 | ['array'], 14 | { object: true }, 15 | Object.create(null), 16 | 'string', 17 | null, 18 | undefined, 19 | NaN, 20 | new Map([[ 1, 'one' ], [ 2, 'two' ]]), 21 | new fn(), 22 | true, 23 | 42, 24 | ] 25 | }) 26 | 27 | describe('isArray', () => { 28 | it('should be true for arrays', () => { 29 | primitives.forEach((val, idx) => { 30 | if (idx === 0) { 31 | expect(isArray(val)).to.be.true 32 | expect(_.isArray(val)).to.be.true 33 | } else { 34 | expect(isArray(val)).to.be.false 35 | expect(_.isArray(val)).to.be.false 36 | } 37 | }) 38 | }) 39 | }) 40 | 41 | describe('isPlainObject', () => { 42 | it('should be true for plain objects', () => { 43 | primitives.forEach((val, idx) => { 44 | if (idx === 1 || idx === 2) { 45 | expect(isPlainObject(val)).to.be.true 46 | expect(_.isPlainObject(val)).to.be.true 47 | } else { 48 | expect(isPlainObject(val)).to.be.false 49 | expect(_.isPlainObject(val)).to.be.false 50 | } 51 | }) 52 | }) 53 | }) 54 | 55 | describe('findKey', () => { 56 | it('should return the matching key', () => { 57 | const obj = { 58 | simple: 1, 59 | obj: { 60 | val: 4, 61 | }, 62 | } 63 | 64 | const checkOne = val => val === 1 65 | const checkTwo = val => typeof val === 'object' 66 | 67 | expect(findKey(obj, checkOne)).to.deep.equal(_.findKey(obj, checkOne)) 68 | expect(findKey(obj, checkTwo)).to.deep.equal(_.findKey(obj, checkTwo)) 69 | }) 70 | }) 71 | 72 | describe('compact', () => { 73 | it('removes falsy values', () => { 74 | const values = [ 75 | true, 76 | false, 77 | 10, 78 | 0, 79 | null, 80 | undefined, 81 | NaN, 82 | '', 83 | 'false, null, 0, "", undefined, and NaN are falsy', 84 | ] 85 | 86 | expect(compact(values)).to.deep.equal(_.compact(values)) 87 | }) 88 | }) 89 | 90 | describe('flatten', () => { 91 | it('flattens an array 1 level', () => { 92 | const value = [1, [2, [3, [4]], 5, [[[6], 7], 8], 9]] 93 | expect(flatten(value)).to.deep.equal(_.flatten(value)) 94 | }) 95 | }) 96 | 97 | describe('map', () => { 98 | it('should map an array', () => { 99 | const values = [1, 2, 3, 4] 100 | const mapFn = val => val * 10 101 | 102 | expect(map(values, mapFn)).to.deep.equal(_.map(values, mapFn)) 103 | expect(map(values, mapFn)).to.deep.equal([10, 20, 30, 40]) 104 | 105 | // ensure that values array is not mutated 106 | expect(values).to.deep.equal([1, 2, 3, 4]) 107 | }) 108 | 109 | it('should map an object', () => { 110 | const obj = { 111 | one: 1, 112 | two: 2, 113 | three: 3, 114 | } 115 | const mapFn = (val, key) => `${key} - ${val * 10}` 116 | 117 | expect(map(obj, mapFn)).to.deep.equal(_.map(obj, mapFn)) 118 | expect(map(obj, mapFn)).to.deep.equal([ 119 | 'one - 10', 120 | 'two - 20', 121 | 'three - 30', 122 | ]) 123 | 124 | // ensure the object was not mutated 125 | expect(obj).to.deep.equal({ 126 | one: 1, 127 | two: 2, 128 | three: 3, 129 | }) 130 | }) 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | module.exports = { 4 | entry: [ 5 | 'webpack-dev-server/client?http://localhost:8080', 6 | 'webpack/hot/dev-server', 7 | `${__dirname}/example/main.js`, 8 | ], 9 | devtool: 'inline-source-map', 10 | debug: true, 11 | output: { 12 | path: `${__dirname}/dist`, 13 | filename: 'index.js', 14 | }, 15 | resolve: { 16 | extensions: ['', '.js'], 17 | }, 18 | resolveLoader: { 19 | modulesDirectories: ['node_modules'], 20 | }, 21 | plugins: [ 22 | new webpack.HotModuleReplacementPlugin(), 23 | ], 24 | module: { 25 | loaders: [ 26 | { 27 | test: /\.less$/, 28 | loader: 'style-loader!css-loader!less-loader', 29 | }, 30 | { 31 | test: /\.js$/, 32 | exclude: /(node_modules|bower_components)/, 33 | loader: 'babel', 34 | }, 35 | ], 36 | noParse: /\.min\.js/, 37 | }, 38 | } 39 | --------------------------------------------------------------------------------