├── .all-contributorsrc ├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE ├── PendingKeyboardEvent.js ├── PendingKeyboardEventManager.js ├── PluginsManager.js ├── README.md ├── dist └── whenipress.js ├── example └── index.html ├── logo.png ├── mix-manifest.json ├── package-lock.json ├── package.json ├── tests ├── plugins.test.js └── whenipress.test.js ├── webpack.mix.js ├── whenipress.d.ts └── whenipress.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "geopic", 10 | "name": "George Pickering", 11 | "avatar_url": "https://avatars0.githubusercontent.com/u/29524044?v=4", 12 | "profile": "https://twitter.com/geopic1", 13 | "contributions": [ 14 | "code" 15 | ] 16 | } 17 | ], 18 | "contributorsPerLine": 7, 19 | "projectName": "whenipress", 20 | "projectOwner": "lukeraymonddowning", 21 | "repoType": "github", 22 | "repoHost": "https://github.com", 23 | "skipCi": true 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [lukeraymonddowning] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Luke Downing 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 | -------------------------------------------------------------------------------- /PendingKeyboardEvent.js: -------------------------------------------------------------------------------- 1 | class PendingKeyboardEvent { 2 | 3 | constructor(manager, ...keys) { 4 | this.scope = null 5 | this.keysCurrentlyBeingPressed = [] 6 | this._keyDownHandler = null 7 | this._keyUpHandler = null 8 | this._stopAfterNextRun = false 9 | this._onlyFireOnDoublePress = false 10 | this._doublePressTimeout = 500 11 | this._pressCount = 0 12 | this._totalKeyDownCountForKeysToWatch = 0 13 | this._totalKeyUpCountForKeysToWatch = 0 14 | this._releasedHandler = null 15 | this.handleEvenOnForms = false 16 | 17 | this._manager = manager 18 | this._pluginsManager = this._manager.pluginsManager 19 | this.keysToWatch = keys 20 | } 21 | 22 | whileFocusIsWithin(element) { 23 | if (typeof element === 'string') { 24 | element = document.querySelector(element) 25 | } 26 | 27 | this.scope = element 28 | 29 | return this 30 | } 31 | 32 | then(handler) { 33 | this.createKeyDownHandler(handler) 34 | this.createKeyUpHandler() 35 | 36 | return this 37 | } 38 | 39 | do(handler) { 40 | return this.then(handler) 41 | } 42 | 43 | run(handler) { 44 | return this.then(handler) 45 | } 46 | 47 | whenReleased(handler) { 48 | this._releasedHandler = handler 49 | 50 | return this 51 | } 52 | 53 | _storeKeyBeingPressed(event) { 54 | if (this.keysToWatch.includes(event.code)) { 55 | return this.keysCurrentlyBeingPressed.push(event.code) 56 | } 57 | 58 | return this.keysCurrentlyBeingPressed.push(event.key) 59 | } 60 | 61 | _resetPressCount() { 62 | this._pressCount = 0 63 | } 64 | 65 | _shouldHandleOrSkipDoublePress() { 66 | if (!this._onlyFireOnDoublePress) { 67 | return true 68 | } 69 | 70 | this._pressCount++ 71 | 72 | if (this._pressCount === 2) { 73 | return true 74 | } 75 | 76 | setTimeout(e => this._resetPressCount(), this._doublePressTimeout) 77 | 78 | return false 79 | } 80 | 81 | _removeReleasedKeyFromKeysBeingPressedArray(event) { 82 | this.keysCurrentlyBeingPressed = [...this.keysCurrentlyBeingPressed].filter(key => { 83 | return key !== event.key && key !== event.code 84 | }) 85 | } 86 | 87 | evenOnForms() { 88 | this.handleEvenOnForms = true 89 | 90 | return this 91 | } 92 | 93 | once() { 94 | this._stopAfterNextRun = true 95 | 96 | return this 97 | } 98 | 99 | twiceRapidly(timeout = 500) { 100 | this._onlyFireOnDoublePress = true 101 | this._doublePressTimeout = timeout 102 | 103 | return this 104 | } 105 | 106 | stop() { 107 | this._manager._childStopped(this) 108 | } 109 | 110 | createKeyDownHandler(handler) { 111 | this._keyDownHandler = event => { 112 | this._storeKeyBeingPressed(event) 113 | 114 | if (!this._isInScope(event.target)) { 115 | return 116 | } 117 | 118 | if (!this._arraysAreEqual(this.keysCurrentlyBeingPressed, this.keysToWatch)) { 119 | return 120 | } 121 | 122 | if (!this._shouldHandleOrSkipDoublePress()) { 123 | return 124 | } 125 | 126 | if (this._pluginsManager.handle('beforeBindingHandled', this).includes(false)) { 127 | return this._resetPressCount() 128 | } 129 | 130 | handler({ 131 | keys: this.keysCurrentlyBeingPressed, 132 | nativeEvent: event, 133 | }) 134 | 135 | this._resetPressCount() 136 | this._totalKeyDownCountForKeysToWatch++ 137 | 138 | this._pluginsManager.handle('afterBindingHandled', this) 139 | 140 | this._stopAfterNextRun && this.stop() 141 | } 142 | } 143 | 144 | _isInScope(element) { 145 | if (!this.handleEvenOnForms && this._isUserInput(element)) { 146 | return false 147 | } 148 | 149 | if (this.scope) { 150 | return this.scope.isSameNode(element) || this.scope.contains(element) 151 | } 152 | 153 | return true 154 | } 155 | 156 | _isUserInput(element) { 157 | return ['INPUT', 'TEXTAREA', 'SELECT'].includes(element.tagName); 158 | } 159 | 160 | createKeyUpHandler() { 161 | this._keyUpHandler = event => { 162 | this._removeReleasedKeyFromKeysBeingPressedArray(event) 163 | 164 | if (this.keysCurrentlyBeingPressed.length !== 0) { 165 | return 166 | } 167 | 168 | if (this._totalKeyDownCountForKeysToWatch <= this._totalKeyUpCountForKeysToWatch) { 169 | return 170 | } 171 | 172 | this._totalKeyUpCountForKeysToWatch = this._totalKeyDownCountForKeysToWatch 173 | 174 | if (!this._releasedHandler) { 175 | return 176 | } 177 | 178 | this._releasedHandler(event) 179 | } 180 | } 181 | 182 | _arraysAreEqual(array1, array2) { 183 | return array1.length === array2.length && array2.every(item => array1.includes(item)) 184 | } 185 | 186 | } 187 | 188 | module.exports = PendingKeyboardEvent -------------------------------------------------------------------------------- /PendingKeyboardEventManager.js: -------------------------------------------------------------------------------- 1 | var PendingKeyboardEvent = require('./PendingKeyboardEvent') 2 | var PluginsManager = require('./PluginsManager') 3 | 4 | class PendingKeyboardEventManager { 5 | 6 | constructor() { 7 | this.registeredEvents = [] 8 | this.modifiers = [] 9 | this.pluginsManager = new PluginsManager(this) 10 | 11 | this.createKeyListeners(); 12 | } 13 | 14 | createKeyListeners() { 15 | document.addEventListener('keydown', event => { 16 | this._passEventToChildHandler(event, '_keyDownHandler') 17 | }) 18 | 19 | document.addEventListener('keyup', event => { 20 | this._passEventToChildHandler(event, '_keyUpHandler') 21 | }) 22 | } 23 | 24 | _passEventToChildHandler(event, handlerName) { 25 | this.registeredEvents.forEach(registeredKeyboardEvent => { 26 | registeredKeyboardEvent[handlerName](event) 27 | }) 28 | } 29 | 30 | register(...keys) { 31 | let keysWithModifiers = [...this.modifiers, ...keys] 32 | var event = new PendingKeyboardEvent(this, ...keysWithModifiers) 33 | this.registeredEvents.push(event) 34 | this.pluginsManager.handle('bindingRegistered', event) 35 | return event 36 | } 37 | 38 | group(keys, handler) { 39 | this.modifiers = typeof keys === 'string' ? [keys] : keys 40 | handler() 41 | this.modifiers = [] 42 | } 43 | 44 | use(...plugins) { 45 | this.pluginsManager.add(...plugins) 46 | this.pluginsManager.handleSpecific(plugins, 'mounted') 47 | } 48 | 49 | pluginWithOptions(plugin, options) { 50 | return {...plugin, options: {...plugin.options, ...options}} 51 | } 52 | 53 | flushPlugins() { 54 | this.pluginsManager = new PluginsManager(this) 55 | } 56 | 57 | bindings() { 58 | return this.registeredEvents.map(event => event.keysToWatch) 59 | } 60 | 61 | stopAll() { 62 | this.registeredEvents.forEach(event => event.stop()) 63 | this.registeredEvents = [] 64 | this.pluginsManager.handle('allBindingsStopped') 65 | } 66 | 67 | _childStopped(child) { 68 | this.registeredEvents = [...this.registeredEvents].filter(event => event !== child) 69 | this.pluginsManager.handle('bindingStopped', child) 70 | } 71 | 72 | } 73 | 74 | module.exports = PendingKeyboardEventManager -------------------------------------------------------------------------------- /PluginsManager.js: -------------------------------------------------------------------------------- 1 | class PluginsManager { 2 | 3 | constructor(manager) { 4 | this._manager = manager 5 | this.plugins = [] 6 | } 7 | 8 | add(...plugins) { 9 | this.plugins = [...this.plugins, ...plugins] 10 | } 11 | 12 | handle(event, ...parameters) { 13 | return this._loopOverPlugins(plugin => this.handlePlugin(plugin, event, ...parameters)) 14 | } 15 | 16 | handleSpecific(plugins, event, ...parameters) { 17 | return this._loopOverPlugins(plugin => this.handlePlugin(plugin, event, ...parameters), plugins) 18 | } 19 | 20 | handlePlugin(plugin, event, ...parameters) { 21 | if (!plugin[event]) { 22 | return 23 | } 24 | 25 | return plugin[event](...parameters, this._manager, plugin) 26 | } 27 | 28 | _loopOverPlugins(action, plugins = this.plugins) { 29 | return plugins.map(plugin => action(plugin)) 30 | } 31 | 32 | } 33 | 34 | module.exports = PluginsManager -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | # whenipress 4 | A gorgeous, simple, tiny JavaScript package to add keyboard bindings into your application. 5 | 6 | ## Table of Contents 7 | * [Features](#features) 8 | * [Installation](#installation) 9 | * [Why use?](#why-use-whenipress) 10 | * [Usage](#using-whenipress) 11 | - [Simple key presses](#listening-for-key-presses) 12 | - [Key combos](#listening-for-key-combinations) 13 | - [Alternatives to `then`](#alternatives-to-then) 14 | - [Stop listening for a single binding](#stop-listening-for-a-single-key-binding) 15 | - [Stop listening for all bindings](#stop-listening-for-all-key-bindings) 16 | - [Retrieve registered bindings](#retrieve-a-list-of-every-registered-key-binding) 17 | - [Listen for an event just once](#listening-for-an-event-just-once) 18 | - [Create keybinding groups](#creating-keybinding-groups) 19 | - [Listen for double taps](#listening-for-double-taps) 20 | - [Listen for keys being released](#listening-for-when-keys-are-released) 21 | - [Keybindings and form elements](#keybindings-and-form-elements) 22 | - [Scoping your bindings to elements](#scoping-your-bindings-to-elements) 23 | * [Extending whenipress](#extending-whenipress) 24 | - [Registering plugins](#registering-plugins) 25 | - [Plugin syntax](#plugin-syntax) 26 | * [Initialising your plugin](#initialising-your-plugin) 27 | * [Listen for new bindings](#listen-for-when-a-new-binding-is-registered) 28 | * [Listen for a stopped binding](#listen-for-when-a-binding-is-stopped) 29 | * [Listen for when all bindings are stopped](#listen-for-when-all-bindings-are-stopped) 30 | * [Hook in before a handler is fired](#hook-in-before-an-event-is-handler-is-fired) 31 | * [Hook in after a handler is fired](#hook-in-after-an-event-has-been-handled) 32 | * [Plugin options](#plugin-options) 33 | * [Stopping plugins](#stopping-plugins) 34 | 35 | 36 | ## Features 37 | - A simple, intuitive syntax for adding keyboard shortcuts for key presses and key combinations. 38 | - Takes the complexity out of key codes and values, allowing you to mix and match to your heart's content. 39 | - Teeny and tiny and dependency free - just 1.4kB minified & gzipped. 40 | - Stores all of your keyboard combinations under a single keydown and keyup listener, improving your app's performance. 41 | - Provides advanced functionality, such as listening for double tapping keys and only listening for a keyboard event once. 42 | - Stores all your key bindings in one place, allowing you to have access to every binding in your application. 43 | - Allows for key groups using the `group` function, making your code more readable and powerful. 44 | - Provides a hook to be notified when a keyboard shortcut has been released. 45 | - Includes a powerful plugin syntax for extending the base functionality of whenipress. 46 | 47 | ## Installation 48 | Whenipress is available via npm: `npm i whenipress`. 49 | You should then require it as a module in your main JavaScript file. 50 | 51 | ```javascript 52 | import whenipress from 'whenipress/whenipress'; 53 | 54 | whenipress('a', 'b', 'c').then(e => console.log('Nice key combo!')); 55 | ``` 56 | 57 | But you can equally use it via a CDN: 58 | 59 | ```html 60 | 61 | 64 | ``` 65 | 66 | ## Why use whenipress? 67 | Keyboard shortcuts are often an add-on in most web applications. Why? Usually, because it can be pretty complicated to 68 | add them in JavaScript. The `keydown` and `keyup` events are pretty low level stuff, and require a fair bit of abstraction 69 | to make adding shortcuts a simple task. 70 | 71 | Say hello to whenipress. We've done all the abstraction for you, and provided the functionality you'll need in a 72 | much simpler, easier to manage way. Getting started is as simple as calling the global `whenipress` method, passing 73 | in the key-combo you want to listen for. Check out our guide below... 74 | 75 | ## Using whenipress 76 | What follows is an in depth look at all the juicy functionality offered to you out of the box by whenipress. Enjoy! 77 | 78 | ### Listening for key presses 79 | So, how do you get started? After you've installed the package using one of the methods described in the 'getting started' 80 | section, you can get to registering your first keyboard shortcut. Let's imagine we want to register a shortcut on the '/' 81 | key that will focus the global search bar in our web application. We've already set up a method, `focusGlobalSearchBar()`, 82 | that will actually focus the input for us. We just need to wire it up to our shortcut. Check it out: 83 | 84 | ```javascript 85 | whenipress('/').then(event => focusGlobalSearchBar()) 86 | ``` 87 | 88 | ### Listening for key combinations 89 | And we're done. Yeah, it's that easy. However, that's also pretty easy to set up in vanilla JavaScript, right? What isn't 90 | so easy to wire up are key combinations. There is no way in native JavaScript to listen for multiple keys at the same time. 91 | Fret not, we have you covered here too. Let's imagine that, when the 'left control' key is pressed in combination with 92 | the 'n' key, we want to redirect to a page where a new CRUD entry can be added. Once again, we've already set up a method, 93 | `redirectToCreateForm()`, that will do the redirecting. Here's how we wire it up: 94 | 95 | ```javascript 96 | whenipress('ControlLeft', 'n').then(event => redirectToCreateForm()) 97 | ``` 98 | 99 | Pretty nice, right? We can pass any number of keys or key codes into the `whenipress` method to set up complex and 100 | powerful shortcuts. 101 | 102 | ### Alternatives to `then` 103 | Because `then` is used in JavaScript promises, some of you may wish to use a different syntax to avoid any confusion. 104 | Whenipress aliases `then` to `do` and `run`, so you can use those instead if you prefer. 105 | 106 | ```javascript 107 | // This... 108 | whenipress('a').then(e => alert('e pressed!')) 109 | 110 | // Is the same as this... 111 | whenipress('a').do(e => alert('e pressed!')) 112 | 113 | // And this... 114 | whenipress('a').run(e => alert('e pressed!')) 115 | ``` 116 | 117 | ### Stop listening for a single key binding 118 | Sometimes, you'll want to disable a key binding. No problem! When you create the key binding, you'll be returned a 119 | reference to it. You can call the `stop` method on that reference at any time to stop listening. 120 | 121 | ```javascript 122 | var nKeyPress = whenipress('n').then(e => console.log('You pressed n')); 123 | 124 | nKeyPress.stop(); 125 | ``` 126 | 127 | Even better, the related event listener will be completely removed from the DOM, keeping performance snappy. 128 | 129 | ### Stop listening for all key bindings 130 | If you wish to stop listening for all registered key bindings, you can call the `stopAll` method on the global 131 | `whenipress` instance. 132 | 133 | ```javascript 134 | whenipress('A', 'B', 'C').then(e => console.log('Foo')); 135 | whenipress('T', 'A', 'N').then(e => console.log('Bar')); 136 | 137 | whenipress().stopAll(); 138 | ``` 139 | 140 | ### Retrieve a list of every registered key binding 141 | Because all key bindings are stored in a single location, it is possible to retrieve them programmatically at any time. 142 | This is super useful in whenipress plugins, where you can't be sure which key bindings have been registered. 143 | 144 | ```javascript 145 | whenipress('n', 'e', 's').then(e => console.log('Foo')); 146 | whenipress('l', 'i', 'h').then(e => console.log('Bar')); 147 | 148 | whenipress().bindings() // Will return [['n', 'e', 's'], ['l', 'i', 'h']] 149 | ``` 150 | 151 | ### Listening for an event just once 152 | Only want to register a key binding for a single press? Just add the `once` modifier! 153 | 154 | ```javascript 155 | whenipress('z').then(e => console.log("z was pressed")).once(); 156 | ``` 157 | 158 | The event listener will be removed the first time it is fired. You can place the `once` modifier before the `then` 159 | call if you wish. 160 | 161 | ### Creating keybinding groups 162 | Whenipress supports key groups for easily adding modifiers without having to repeat yourself over and over. 163 | 164 | ```javascript 165 | whenipress().group('Shift', () => { 166 | whenipress('b').then(e => console.log('Shift + b pressed')); 167 | whenipress('c').then(e => console.log('Shift + c pressed')); 168 | }); 169 | ``` 170 | 171 | ### Listening for double taps 172 | Want to listen for keys pressed twice in quick succession? We have you covered. You can even alter the timeout between 173 | key presses. 174 | 175 | ```javascript 176 | whenipress('a').twiceRapidly().then(e => console.log('You just double pressed the a key')); 177 | 178 | // Use a 300ms timeout 179 | whenipress('a').twiceRapidly(300).then(e => console.log('You just double pressed the a key')); 180 | ``` 181 | 182 | ### Listening for when keys are released 183 | The `then` callback you provide whenipress will be fired as soon as all keys in the binding are pressed down at the same 184 | time. Sometimes, however, you'll want to listen for when the keys are released too. No sweat here! 185 | 186 | ```javascript 187 | whenipress('a', 'b', 'c') 188 | .then(e => { 189 | console.log('Keys are pressed!'); 190 | }) 191 | .whenReleased(e => { 192 | console.log('Keys are released!'); 193 | }); 194 | ``` 195 | 196 | ### Keybindings and form elements 197 | By default, whenipress will ignore keybindings on form elements like inputs, textareas, and select boxes so that you 198 | don't have unexpected side effects in your application. To override this functionality and cause a keybinding to 199 | fire even on these form elements, you may tag `evenOnForms` on to the end of your binding registration. 200 | 201 | ```javascript 202 | whenipress('LeftShift', 'KeyA').then(e => alert("I work, even in inputs, textareas and selects!")).evenOnForms() 203 | ``` 204 | 205 | ### Scoping your bindings to elements 206 | Sometimes, you may only want a keyboard event to fire if a node or children within that node are currently in focus. 207 | For example, you may have a sidebar menu where, only when opened, you would like the escape key to close the menu for you. 208 | 209 | Whenipress allows you to do this using the `whileFocusIsWithin` method. This method accepts a query selector or an Element. 210 | 211 | ```javascript 212 | whenipress('Escape').whileFocusIsWithin('#slideout-menu').then(e => closeMenu()) 213 | 214 | // Or... 215 | whenipress('Escape').whileFocusIsWithin(document.querySelector('#slideout-menu')).then(e => closeMenu()) 216 | ``` 217 | 218 | Whenipress will make sure that the `#slideout-menu` or one of its descendents has focus before executing your callback. 219 | 220 | 221 | ## Extending whenipress 222 | Whenipress was created to be extended. Whilst it offers tons of functionality out of the box, it can do so much more with a plugin. 223 | What follows is a brief guide on how to get started creating your own plugins for whenipress. 224 | 225 | > Created a great plugin that you think would benefit the community? Create an issue for it and we'll link you here! 226 | 227 | ### Registering plugins 228 | To register a plugin in your application, whenipress provides a `use` method. 229 | 230 | ```javascript 231 | import whenipress from 'whenipress/whenipress' 232 | import plugin from 'awesomeplugin/plugin' 233 | import anotherPlugin from 'thegreatplugin/plugin' 234 | 235 | whenipress().use(plugin, anotherPlugin) 236 | ``` 237 | 238 | ### Plugin syntax 239 | Whenipress plugins are essentially JSON objects. The properties on that JSON object will be called by whenipress during 240 | different stages of the process, allowing you to hook in and perform any functionality you can think of. You do not 241 | need to include every hook, only the ones you're interested in using for your plugin. 242 | 243 | What follows is a list of available hooks. 244 | 245 | #### Initialising your plugin 246 | If you need to perform a setup step in your plugin, you should use the `mounted` hook. It is called when your plugin 247 | is first registered by the user. This receives the global `whenipress` instance as a parameter. 248 | You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 249 | a different name. 250 | 251 | ```javascript 252 | const myPlugin = { 253 | mounted: globalInstance => { 254 | alert('Hello world!') 255 | globalInstance.register('a', 'b', 'c').then(e => alert('You pressed a, b and c')) 256 | } 257 | } 258 | ``` 259 | 260 | Note that in a plugin, we can register new keyboard bindings using the `register` method on the globalInstance. 261 | 262 | #### Listen for when a new binding is registered 263 | If you want to be notified every time a new key combination is registered with whenipress, you can use the `bindingRegistered` 264 | hook. It will receive the binding that was registered as the first parameter and the global `whenipress` instance as the second 265 | parameter. You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 266 | a different name. 267 | 268 | ```javascript 269 | const myPlugin = { 270 | bindingRegistered: (binding, globalInstance) => { 271 | alert(`I'm now listening for any time you press ${binding.join(" + ")}.`) 272 | } 273 | } 274 | ``` 275 | 276 | Note that it is not guaranteed the user has initialised your plugin prior to creating their bindings. If you need to ensure 277 | you have all bindings, you should iterate over registered bindings in your plugin's mounted method. 278 | 279 | #### Listen for when a binding is stopped 280 | If you wish to be notified of when a binding has been removed from whenipress, you can use the `bindingStopped` hook. 281 | It will receive the binding that was stopped as the first parameter and the global `whenipress` instance as the second 282 | parameter. You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 283 | a different name. 284 | 285 | ```javascript 286 | const myPlugin = { 287 | bindingStopped: (binding, globalInstance) => { 288 | alert(`You are no longer listening for ${binding.join(" + ")}.`) 289 | } 290 | } 291 | ``` 292 | 293 | #### Listen for when all bindings are stopped 294 | To be informed when all bindings in the application are stopped, you should use the `allBindingsStopped` hook. 295 | This receives the global `whenipress` instance as a parameter. 296 | You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 297 | a different name. 298 | 299 | ```javascript 300 | const myPlugin = { 301 | allBindingsStopped: globalInstance => { 302 | alert(`Do not go gently into that good night...`) 303 | } 304 | } 305 | ``` 306 | 307 | #### Hook in before an event is handler is fired 308 | It may be useful to perform an action just before a keyboard shortcut is handled. Say hello to the `beforeBindingHandled` hook. 309 | It will receive the binding that is to be handled as the first parameter and the global `whenipress` instance as the second 310 | parameter. You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 311 | a different name. 312 | 313 | ```javascript 314 | const myPlugin = { 315 | beforeBindingHandled: (binding, globalInstance) => { 316 | alert(`You just pressed ${binding.join(" + ")}, but I got here first.`) 317 | } 318 | } 319 | ``` 320 | 321 | You can actually prevent the handler from ever firing by returning `false` in you hook. This is useful if your plugin 322 | adds conditional functionality. 323 | 324 | ```javascript 325 | const myPlugin = { 326 | beforeBindingHandled: (binding, globalInstance) => { 327 | if (userHasDisabledKeyboardShortcuts()) { 328 | return false; 329 | } 330 | } 331 | } 332 | ``` 333 | 334 | #### Hook in after an event has been handled 335 | You may wish to know when a keyboard binding has been handled. You can use the `afterBindingHandled` hook for this. 336 | It will receive the binding that has been handled as the first parameter and the global `whenipress` instance as the second 337 | parameter. You should use this in your plugin instead of calling `whenipress` as the end user may have aliased `whenipress` under 338 | a different name. 339 | 340 | ```javascript 341 | const myPlugin = { 342 | afterBindingHandled: (binding, globalInstance) => { 343 | alert(`You just pressed ${binding.join(" + ")}. It has been handled, but now I'm going to do something as well.`) 344 | } 345 | } 346 | ``` 347 | 348 | #### Plugin options 349 | Whenipress provides a unified method of handling custom options to users of your plugin. To do so, register an `options` 350 | field in your plugin JSON. You can include anything in here that you want, but be sure to let your users know in your 351 | plugin's documentation. 352 | 353 | In our example below, we have decided to provide to options to the user, `urlsToSkip` and `skipAllUrls`. These are 354 | completely unique to your plugin and you're in charge of managing them. 355 | 356 | ```javascript 357 | const myPlugin = { 358 | options: { 359 | urlsToSkip: [], 360 | skipAllUrls: false 361 | } 362 | } 363 | ``` 364 | 365 | Now, when your plugin is registered, these options can be overridden by the user. 366 | 367 | ```javascript 368 | import whenipress from 'whenipress/whenipress' 369 | import myPlugin from 'awesomeplugin/myPlugin' 370 | 371 | whenipress().use(whenipress().pluginWithOptions(myPlugin, { skipAllUrls: true })) 372 | ``` 373 | 374 | #### Stopping plugins 375 | If you wish to stop all plugins running, you may use the `flushPlugins` method. 376 | 377 | ```javascript 378 | whenipress().flushPlugins() 379 | ``` 380 | ## Contributors ✨ 381 | 382 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 |

George Pickering

💻
392 | 393 | 394 | 395 | 396 | 397 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! -------------------------------------------------------------------------------- /dist/whenipress.js: -------------------------------------------------------------------------------- 1 | !function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=0)}([function(e,t,n){e.exports=n(1)},function(e,t,n){(function(t){var r=new(n(3));t.whenipress=function(){return 0===arguments.length?r:r.register.apply(r,arguments)},e.exports=whenipress}).call(this,n(2))},function(e,t){var n;n=function(){return this}();try{n=n||new Function("return this")()}catch(e){"object"==typeof window&&(n=window)}e.exports=n},function(e,t,n){function r(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function o(e){for(var t=1;te.length)&&(t=e.length);for(var n=0,r=new Array(t);ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n1?n-1:0),i=1;i0&&void 0!==arguments[0]?arguments[0]:500;return this._onlyFireOnDoublePress=!0,this._doublePressTimeout=e,this}},{key:"stop",value:function(){this._manager._childStopped(this)}},{key:"createKeyDownHandler",value:function(e){var t=this;this._keyDownHandler=function(n){if(t._storeKeyBeingPressed(n),t._isInScope(n.target)&&t._arraysAreEqual(t.keysCurrentlyBeingPressed,t.keysToWatch)&&t._shouldHandleOrSkipDoublePress()){if(t._pluginsManager.handle("beforeBindingHandled",t).includes(!1))return t._resetPressCount();e({keys:t.keysCurrentlyBeingPressed,nativeEvent:n}),t._resetPressCount(),t._totalKeyDownCountForKeysToWatch++,t._pluginsManager.handle("afterBindingHandled",t),t._stopAfterNextRun&&t.stop()}}}},{key:"_isInScope",value:function(e){return!(!this.handleEvenOnForms&&this._isUserInput(e))&&(!this.scope||this.scope.isSameNode(e)||this.scope.contains(e))}},{key:"_isUserInput",value:function(e){return["INPUT","TEXTAREA","SELECT"].includes(e.tagName)}},{key:"createKeyUpHandler",value:function(){var e=this;this._keyUpHandler=function(t){e._removeReleasedKeyFromKeysBeingPressedArray(t),0===e.keysCurrentlyBeingPressed.length&&(e._totalKeyDownCountForKeysToWatch<=e._totalKeyUpCountForKeysToWatch||(e._totalKeyUpCountForKeysToWatch=e._totalKeyDownCountForKeysToWatch,e._releasedHandler&&e._releasedHandler(t)))}}},{key:"_arraysAreEqual",value:function(e,t){return e.length===t.length&&t.every((function(t){return e.includes(t)}))}}])&&i(t.prototype,r),u&&i(t,u),e}();e.exports=u},function(e,t){function n(e){return function(e){if(Array.isArray(e))return r(e)}(e)||function(e){if("undefined"!=typeof Symbol&&Symbol.iterator in Object(e))return Array.from(e)}(e)||function(e,t){if(!e)return;if("string"==typeof e)return r(e,t);var n=Object.prototype.toString.call(e).slice(8,-1);"Object"===n&&e.constructor&&(n=e.constructor.name);if("Map"===n||"Set"===n)return Array.from(e);if("Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n))return r(e,t)}(e)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function r(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=new Array(t);n1?n-1:0),o=1;o2?r-2:0),i=2;i2?n-2:0),o=2;o1&&void 0!==arguments[1]?arguments[1]:this.plugins;return t.map((function(t){return e(t)}))}}])&&o(t.prototype,r),i&&o(t,i),e}();e.exports=i}]); -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | A demo of whenipress 6 | 7 | 8 | 9 |
10 |
11 |
12 | 13 | 14 |
15 |
16 |
17 | 18 | 19 | 22 | 23 | 24 | 25 | 47 | 48 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeraymonddowning/whenipress/294b7f1b8929865958c054fe652d232caa9bad75/logo.png -------------------------------------------------------------------------------- /mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/dist/whenipress.js": "/dist/whenipress.js" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "whenipress", 3 | "version": "1.8.0", 4 | "description": "A small, simple library that makes working with key presses in JS simple, intuitive and declarative.", 5 | "main": "whenipress.js", 6 | "types": "whenipress.d.ts", 7 | "scripts": { 8 | "test": "jest", 9 | "dev": "npm run development", 10 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 11 | "watch": "npm run development -- --watch", 12 | "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 13 | "prod": "npm run production", 14 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 15 | }, 16 | "author": "Luke Downing", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "cross-env": "^7.0.2", 20 | "jest": "^26.0.1", 21 | "laravel-mix": "^5.0.4", 22 | "vue-template-compiler": "^2.6.11" 23 | }, 24 | "dependencies": {} 25 | } 26 | -------------------------------------------------------------------------------- /tests/plugins.test.js: -------------------------------------------------------------------------------- 1 | const whenipress = require('./../whenipress') 2 | const PendingKeyboardEvent = require('./../PendingKeyboardEvent') 3 | 4 | afterEach(() => { 5 | whenipress().stopAll() 6 | whenipress().flushPlugins() 7 | }) 8 | 9 | test('it can be notified of when a new binding is registered', done => { 10 | const examplePlugin = { 11 | bindingRegistered: (binding, globalInstance) => { 12 | expect(binding).toBeInstanceOf(PendingKeyboardEvent) 13 | expect(binding.keysToWatch).toEqual(['a']) 14 | expect(globalInstance.bindings().length).toBe(1) 15 | done() 16 | } 17 | } 18 | 19 | whenipress().use(examplePlugin) 20 | 21 | whenipress('a').then(e => {}) 22 | }) 23 | 24 | test('it can be notified of when all bindings are stopped', done => { 25 | const plugin = { 26 | allBindingsStopped: globalInstance => { 27 | expect(globalInstance.bindings().length).toBe(0) 28 | done() 29 | } 30 | } 31 | 32 | whenipress().use(plugin) 33 | 34 | whenipress('a').then(e => {}) 35 | expect(whenipress().bindings().length).toBe(1) 36 | whenipress().stopAll() 37 | }) 38 | 39 | test('it may be notified of when a single binding is stopped', done => { 40 | const plugin = { 41 | bindingStopped: (keys, globalInstance) => { 42 | expect(keys).toBeInstanceOf(PendingKeyboardEvent) 43 | expect(keys.keysToWatch).toEqual(['a']) 44 | expect(globalInstance.bindings().length).toBe(0) 45 | done() 46 | } 47 | } 48 | 49 | whenipress().use(plugin) 50 | 51 | let binding = whenipress('a').then(e => {}) 52 | expect(whenipress().bindings().length).toBe(1) 53 | binding.stop() 54 | }) 55 | 56 | test('it may hook in directly before an event handler', () => { 57 | var eventFiredCount = 0 58 | var beforeHandled = false 59 | 60 | const plugin = { 61 | beforeBindingHandled: (keys, globalInstance) => { 62 | expect(keys).toBeInstanceOf(PendingKeyboardEvent) 63 | expect(keys.keysToWatch).toEqual(['a']) 64 | expect(eventFiredCount).toBe(0) 65 | beforeHandled = true 66 | } 67 | } 68 | 69 | whenipress().use(plugin) 70 | whenipress('a').then(e => eventFiredCount++) 71 | 72 | press('a') 73 | expect(eventFiredCount).toBe(1) 74 | expect(beforeHandled).toBeTruthy() 75 | }) 76 | 77 | test('the pre event handler hook may interrupt the handler', () => { 78 | var eventFiredCount = 0 79 | var beforeHandled = false 80 | 81 | const plugin = { 82 | beforeBindingHandled: (keys, globalInstance) => { 83 | expect(eventFiredCount).toBe(0) 84 | beforeHandled = true 85 | 86 | return false 87 | } 88 | } 89 | 90 | whenipress().use(plugin) 91 | whenipress('a').then(e => eventFiredCount++) 92 | 93 | press('a') 94 | expect(eventFiredCount).toBe(0) 95 | expect(beforeHandled).toBeTruthy() 96 | }) 97 | 98 | test('it may hook into the post event handler', () => { 99 | var eventFiredCount = 0 100 | var postHandled = false 101 | 102 | const plugin = { 103 | afterBindingHandled: (keys, globalInstance) => { 104 | expect(keys).toBeInstanceOf(PendingKeyboardEvent) 105 | expect(keys.keysToWatch).toEqual(['a']) 106 | expect(eventFiredCount).toBe(1) 107 | postHandled = true 108 | } 109 | } 110 | 111 | whenipress().use(plugin) 112 | whenipress('a').then(e => eventFiredCount++) 113 | 114 | press('a') 115 | expect(eventFiredCount).toBe(1) 116 | expect(postHandled).toBeTruthy() 117 | }) 118 | 119 | test('it can hook into the constructor', () => { 120 | var eventFiredCount = 0 121 | 122 | const plugin = { 123 | mounted: globalInstance => { 124 | globalInstance.register('a').then(e => eventFiredCount++) 125 | } 126 | } 127 | 128 | whenipress().use(plugin) 129 | 130 | press('a') 131 | 132 | expect(eventFiredCount).toBe(1) 133 | }) 134 | 135 | test('a plugin may omit options', () => { 136 | const examplePlugin = {} 137 | 138 | whenipress().use(examplePlugin) 139 | 140 | whenipress('a').then(e => {}) 141 | 142 | expect(whenipress().pluginsManager.plugins.length).toBe(1) 143 | }) 144 | 145 | test('multiple plugins may be registered', () => { 146 | var pluginCalledCount = 0 147 | 148 | const examplePlugin = { 149 | bindingRegistered: (binding, globalInstance) => { 150 | pluginCalledCount++ 151 | } 152 | } 153 | 154 | whenipress().use(examplePlugin, examplePlugin) 155 | 156 | whenipress('a').then(e => {}) 157 | 158 | expect(pluginCalledCount).toBe(2) 159 | }) 160 | 161 | test('plugins may be cleared', () => { 162 | whenipress().use({}) 163 | 164 | expect(whenipress().pluginsManager.plugins.length).toBe(1) 165 | 166 | whenipress().flushPlugins() 167 | 168 | expect(whenipress().pluginsManager.plugins.length).toBe(0) 169 | }) 170 | 171 | test('plugins may allow users to specify custom options', () => { 172 | var eventFiredCount = 0 173 | 174 | const plugin = { 175 | mounted: (globalInstance, self) => { 176 | globalInstance.register(self.options.binding).then(e => self.options.handler(e)) 177 | }, 178 | options: { 179 | binding: 'a', 180 | handler: e => eventFiredCount++ 181 | } 182 | } 183 | 184 | whenipress().use(plugin) 185 | press('a') 186 | 187 | whenipress().flushPlugins() 188 | whenipress().use(whenipress().pluginWithOptions(plugin, { binding: 'b' })) 189 | press('b') 190 | 191 | expect(eventFiredCount).toBe(2) 192 | }) 193 | 194 | function press(...keys) { 195 | keys.forEach(key => dispatchKeyDown(key)) 196 | keys.forEach(key => dispatchKeyUp(key)) 197 | } 198 | 199 | function dispatchKeyDown(key) { 200 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': key})) 201 | } 202 | 203 | function dispatchKeyUp(key) { 204 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': key})) 205 | } -------------------------------------------------------------------------------- /tests/whenipress.test.js: -------------------------------------------------------------------------------- 1 | const whenipress = require('./../whenipress') 2 | 3 | afterEach(() => { 4 | whenipress().stopAll() 5 | }) 6 | 7 | test('registers an event listener for the given alphanumeric', done => { 8 | let testHelpers = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789/_-*()[]{}<>?\\|:;!@£$%^&' 9 | 10 | testHelpers.split('').forEach(letter => { 11 | whenipress(letter).then(e => { 12 | expect(e.keys).toEqual([letter]) 13 | done() 14 | }) 15 | 16 | press(letter) 17 | }) 18 | }) 19 | 20 | test('it can be passed key codes instead of values', () => { 21 | var eventFiredCount = 0 22 | 23 | let wip = whenipress('Shift', 'Digit2').then(e => eventFiredCount++) 24 | 25 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': 'Shift'})) 26 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': '@', 'code': 'Digit2'})) 27 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': 'Shift'})) 28 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': '@', 'code': 'Digit2'})) 29 | 30 | expect(wip.keysCurrentlyBeingPressed).toEqual([]) 31 | 32 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': 'Shift'})) 33 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': '@', 'code': 'Digit2'})) 34 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': 'Shift'})) 35 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': '@', 'code': 'Digit2'})) 36 | 37 | expect(eventFiredCount).toBe(2) 38 | }) 39 | 40 | test('it can use the do method as an alias for then', () => { 41 | var eventFiredCount = 0 42 | 43 | whenipress('a').do(e => eventFiredCount++) 44 | 45 | press('a') 46 | 47 | expect(eventFiredCount).toBe(1) 48 | }) 49 | 50 | test('it can use the run method as an alias for then', () => { 51 | var eventFiredCount = 0 52 | 53 | whenipress('a').run(e => eventFiredCount++) 54 | 55 | press('a') 56 | 57 | expect(eventFiredCount).toBe(1) 58 | }) 59 | 60 | test('can be given multiple parameters for key combinations', done => { 61 | whenipress('a', 'b', 'c').then(e => { 62 | expect(e.keys).toEqual(['b', 'a', 'c']) 63 | done() 64 | }) 65 | 66 | press('b', 'a', 'c') 67 | }) 68 | 69 | test('can register multiple different key combinations that share similar keys', done => { 70 | var pressedKeys = [] 71 | whenipress('g', 'h', 'i').then(e => pressedKeys.push(e.keys)) 72 | whenipress('d', 't', 'o').then(e => pressedKeys.push(e.keys)) 73 | 74 | press('g', 'h', 'i') 75 | press('d', 't', 'o') 76 | 77 | expect(pressedKeys[0]).toEqual(['g', 'h', 'i']) 78 | expect(pressedKeys[1]).toEqual(['d', 't', 'o']) 79 | done() 80 | }) 81 | 82 | test('can fire multiple times', done => { 83 | eventFiredCount = 0 84 | 85 | whenipress('a').then(values => { 86 | eventFiredCount++ 87 | 88 | if (eventFiredCount === 2) { 89 | done() 90 | } 91 | }) 92 | 93 | press('a') 94 | press('a') 95 | }) 96 | 97 | test('can cleanup event listeners', done => { 98 | eventFiredCount = 0 99 | 100 | var wip = whenipress('p').then(values => { 101 | eventFiredCount++ 102 | }) 103 | 104 | var otherWip = whenipress('o').then(e => { 105 | }) 106 | 107 | expect(whenipress().bindings().length).toBe(2) 108 | 109 | press('p') 110 | press('p') 111 | 112 | wip.stop() 113 | 114 | press('p') 115 | 116 | expect(eventFiredCount).toBe(2) 117 | expect(whenipress().bindings().length).toBe(1) 118 | 119 | done() 120 | }) 121 | 122 | test('can stop all event listeners', () => { 123 | eventFiredCount = 0 124 | 125 | whenipress('a', 'b', 'c').then(e => eventFiredCount++) 126 | whenipress('c', 'f', 'g').then(e => eventFiredCount++) 127 | 128 | press('a', 'b', 'c') 129 | press('a', 'b', 'c') 130 | press('c', 'f', 'g') 131 | press('c', 'f', 'g') 132 | 133 | whenipress().stopAll() 134 | 135 | press('a', 'b', 'c') 136 | press('a', 'b', 'c') 137 | press('c', 'f', 'g') 138 | press('c', 'f', 'g') 139 | 140 | expect(eventFiredCount).toBe(4) 141 | expect(whenipress().bindings().length).toBe(0) 142 | }) 143 | 144 | test('can retrieve all registered bindings', () => { 145 | whenipress('n', 'e', 's').then(e => { 146 | }) 147 | whenipress('l', 'i', 'h').then(e => { 148 | }) 149 | 150 | expect(whenipress().bindings()).toEqual([['n', 'e', 's'], ['l', 'i', 'h']]) 151 | }) 152 | 153 | test('can listen for an event only once', () => { 154 | eventFiredCount = 0 155 | 156 | whenipress('z').then(e => eventFiredCount++).once() 157 | 158 | press('z') 159 | press('z') 160 | press('z') 161 | press('z') 162 | 163 | expect(eventFiredCount).toBe(1) 164 | }) 165 | 166 | test('can place the once modifier anywhere in the chain', () => { 167 | eventFiredCount = 0 168 | 169 | whenipress('z').once().then(e => eventFiredCount++) 170 | 171 | press('z') 172 | press('z') 173 | press('z') 174 | press('z') 175 | 176 | expect(eventFiredCount).toBe(1) 177 | }) 178 | 179 | test('only fires if the exact keys are being pressed', () => { 180 | eventFiredCount = 0 181 | 182 | whenipress('z', 'a').then(e => eventFiredCount++) 183 | 184 | press('z', 'c', 'a') 185 | 186 | expect(eventFiredCount).toBe(0) 187 | }) 188 | 189 | test('it can have a grouped key modifier', () => { 190 | eventFiredCount = 0 191 | 192 | whenipress().group(['a', 'z'], () => { 193 | whenipress('b').then(e => eventFiredCount++) 194 | whenipress('c').then(e => eventFiredCount++) 195 | }) 196 | 197 | press('b') 198 | press('c') 199 | press('a', 'b') 200 | press('a', 'c') 201 | press('a', 'c', 'z') 202 | press('a', 'b', 'z') 203 | 204 | expect(eventFiredCount).toBe(2) 205 | }) 206 | 207 | test('a single string group modifier may be passed', () => { 208 | eventFiredCount = 0 209 | 210 | whenipress().group('Shift', () => { 211 | whenipress('b').then(e => eventFiredCount++) 212 | whenipress('c').then(e => eventFiredCount++) 213 | }) 214 | 215 | press('b') 216 | press('c') 217 | press('Shift', 'b') 218 | press('Shift', 'c') 219 | 220 | expect(eventFiredCount).toBe(2) 221 | }) 222 | 223 | test('it can listen for double taps', done => { 224 | eventFiredCount = 0 225 | 226 | whenipress('a').twiceRapidly().then(e => eventFiredCount++) 227 | 228 | press('a') 229 | press('a') 230 | 231 | press('a') 232 | setTimeout(e => { 233 | press('a') 234 | expect(eventFiredCount).toBe(1) 235 | done() 236 | }, 600) 237 | }) 238 | 239 | test('the double tap timeout can be altered', done => { 240 | eventFiredCount = 0 241 | 242 | whenipress('a').twiceRapidly(300).then(e => eventFiredCount++) 243 | 244 | press('a') 245 | press('a') 246 | 247 | press('a') 248 | setTimeout(e => { 249 | press('a') 250 | expect(eventFiredCount).toBe(1) 251 | done() 252 | }, 350) 253 | }) 254 | 255 | test('it can listen for keys release', () => { 256 | 257 | var keysPressed = false 258 | 259 | whenipress('a', 'b', 'c') 260 | .then(e => { 261 | keysPressed = true 262 | }) 263 | .whenReleased(e => { 264 | keysPressed = false 265 | }) 266 | 267 | expect(keysPressed).toBeFalsy() 268 | dispatchKeyDown('a') 269 | dispatchKeyDown('b') 270 | dispatchKeyDown('c') 271 | expect(keysPressed).toBeTruthy() 272 | dispatchKeyUp('a') 273 | expect(keysPressed).toBeTruthy() 274 | dispatchKeyUp('b') 275 | dispatchKeyUp('c') 276 | expect(keysPressed).toBeFalsy() 277 | 278 | }) 279 | 280 | test('it will only fire the when released if the active shortcut was released', () => { 281 | var releasedEventFired = false 282 | 283 | whenipress('a') 284 | .then(e => {}) 285 | .whenReleased(e => releasedEventFired = true) 286 | 287 | press('b') 288 | press('c', 'b', 'a') 289 | press('z') 290 | press('x') 291 | expect(releasedEventFired).toBeFalsy() 292 | 293 | press('a') 294 | expect(releasedEventFired).toBeTruthy() 295 | }) 296 | 297 | test('it includes the native event in the event object', () => { 298 | var nativeEvent; 299 | 300 | whenipress('a').then(e => nativeEvent = e.nativeEvent); 301 | 302 | press('a') 303 | 304 | expect(nativeEvent).toBeInstanceOf(KeyboardEvent) 305 | }); 306 | 307 | test('the evenOnForms modifier can be called to make the keyboard shortcut fire even if the user has an input focused', () => { 308 | const noForms = whenipress('c', 'b', 'a').then(e => {}) 309 | 310 | const evenForms = whenipress('a', 'b', 'c').then(e => {}).evenOnForms() 311 | 312 | expect(noForms.handleEvenOnForms).toEqual(false) 313 | expect(evenForms.handleEvenOnForms).toEqual(true) 314 | }) 315 | 316 | test('a binding can be scoped to a given node', () => { 317 | document.body.innerHTML = `
` 318 | 319 | const foobar = document.querySelector('#foobar') 320 | 321 | const binding = whenipress('a').whileFocusIsWithin(foobar).then(e => {}) 322 | 323 | expect(binding.scope).toEqual(foobar) 324 | }) 325 | 326 | test('a binding can be scoped to a given query selector', () => { 327 | document.body.innerHTML = `
` 328 | 329 | const foobar = document.querySelector('#foobar') 330 | 331 | const binding = whenipress('a').whileFocusIsWithin('#foobar').then(e => {}) 332 | 333 | expect(binding.scope).toEqual(foobar) 334 | }) 335 | 336 | function press(...keys) { 337 | keys.forEach(key => dispatchKeyDown(key)) 338 | keys.forEach(key => dispatchKeyUp(key)) 339 | } 340 | 341 | function dispatchKeyDown(key) { 342 | document.dispatchEvent(new KeyboardEvent('keydown', {'key': key})) 343 | } 344 | 345 | function dispatchKeyUp(key) { 346 | document.dispatchEvent(new KeyboardEvent('keyup', {'key': key})) 347 | } -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | 3 | mix.js('./whenipress.js', 'dist/whenipress.js') -------------------------------------------------------------------------------- /whenipress.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'whenipress' { 2 | type Handler = (event?: Event) => void; 3 | 4 | type WhenIPressPlugin = Partial<{ 5 | mounted: (globalInstance: PendingKeyboardEventManager) => void; 6 | bindingRegistered: (binding: string[], globalInstance: PendingKeyboardEventManager) => void; 7 | bindingStopped: (binding: string[], globalInstance: PendingKeyboardEventManager) => void; 8 | allBindingsStopped: (globalInstance: PendingKeyboardEventManager) => void; 9 | beforeBindingHandled: (binding: string[], globalInstance: PendingKeyboardEventManager) => boolean | void; 10 | afterBindingHandled: (binding: string[], globalInstance: PendingKeyboardEventManager) => void; 11 | }> 12 | 13 | class PendingKeyboardEvent { 14 | constructor(manager: PendingKeyboardEventManager, ...keys: string[]); 15 | then(handler: Handler): this; 16 | do(handler: Handler): this; 17 | run(handler: Handler): this; 18 | whenReleased(handler: Handler): this; 19 | once(): this; 20 | twiceRapidly(timeout?: number): this; 21 | evenOnForms(): this; 22 | whileFocusIsWithin(element: string | Element): this; 23 | stop(): void; 24 | createKeyDownHandler(handler: Handler): void; 25 | createKeyUpHandler(handler: Handler): void; 26 | } 27 | 28 | class PendingKeyboardEventManager { 29 | registerFocusListeners(): void; 30 | register(...keys: string[]): PendingKeyboardEvent; 31 | group(keys: string | string[], handler: Handler): void; 32 | use(...plugins: WhenIPressPlugin[]): void; 33 | pluginWithOptions(plugin: WhenIPressPlugin, options: T): { 34 | plugin: WhenIPressPlugin; 35 | options: T; 36 | } 37 | flushPlugins(): void; 38 | bindings(): string[][]; 39 | stopAll(): void; 40 | } 41 | 42 | export default function whenipress(): PendingKeyboardEventManager; 43 | export default function whenipress(...keys: string[]): PendingKeyboardEvent; 44 | } 45 | -------------------------------------------------------------------------------- /whenipress.js: -------------------------------------------------------------------------------- 1 | var PendingKeyboardEventManager = require('./PendingKeyboardEventManager') 2 | 3 | const manager = new PendingKeyboardEventManager(); 4 | 5 | global.whenipress = (...keys) => { 6 | if (keys.length === 0) { 7 | return manager 8 | } 9 | 10 | return manager.register(...keys); 11 | } 12 | 13 | module.exports = whenipress --------------------------------------------------------------------------------