diff --git a/README.md b/README.md index c37ea79e..69600d9f 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,8 @@ See the [upgrade notes](https://github.com/greena13/react-hotkeys/releases/tag/v - [Browser key names](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values) and [Mousetrap syntax](https://github.com/ccampbell/mousetrap) - Define [global](#GlobalHotKeys-component) and [in-focus](#HotKeys-component) hot keys - [Display a list of available hot keys to the user](#Displaying-a-list-of-available-hot-keys) -- [Defining custom key codes](#defining-custom-key-codes) for WebOS and other environments +- [Define custom key codes](#defining-custom-key-codes) for WebOS and other environments +- Allow users to [set their own keyboard shortcuts](#setting-dynamic-hotkeys) - Works with React's Synthetic KeyboardEvents and event delegation and provides [predictable and expected behaviour](#Interaction-with-React) to anyone familiar with React - Optimized by default, but allows you to turn off different optimisation measures in a granular fashion - Customizable through a simple [configuration API](#Configuration) @@ -93,6 +94,7 @@ export default MyNode; - [Specifying key map display data](#specifying-key-map-display-data) - [Deciding which key map syntax to use](#deciding-which-key-map-syntax-to-use) - [Defining custom key codes](#defining-custom-key-codes) + - [Setting dynamic hotkeys](#setting-dynamic-hotkeys) - [Defining Handlers](#defining-handlers) - [DEPRECATED: Hard Sequence Handlers](#deprecated-hard-sequence-handlers) - [Interaction with React](#interaction-with-react) @@ -372,6 +374,119 @@ const keyMap = { }; ``` +#### Setting dynamic hotkeys + +`react-hotkeys` has basic support for setting dynamic hotkeys - i.e. letting the user set their own keyboard shortcuts. In your app, you can set up the necessary UI for [viewing the current keyboard shortcuts](#displaying-a-list-of-available-hot-keys), and opting to change them. You can then use the `recordKeyCombination` function to capture the keys the user wishes to use. + +`recordKeyCombination` accepts a callback function that will be called on the last `keyup` of the next key combination - immediately after the user has pressed the key combination they wish to assign. The callback then unbinds itself, so you do not have to worry about tidying up after it. + +`recordKeyCombination` returns a function you can call at any time after binding the listener, to cancel listening without waiting for the key combination to complete. + +The callback function receives a single argument with the following schema: + +```javascript +{ + /** + * Id of combination that could be used to define a keymap + */ + id: '', + /** + * Dictionary of keys involved in the combination + */ + keys: { keyName: true } +} +``` + +A basic example is: + +```javascript +import { recordKeyCombination } from 'react-hotkeys'; + +renderDialog(){ + if (this.state.showShortcutsDialog) { + const keyMap = getApplicationKeyMap(); + + return ( +
+

+ Keyboard shortcuts +

+ + + + { + Object.keys(keyMap).reduce((memo, actionName) => { + const { sequences, name } = keyMap[actionName]; + + memo.push( + + + + + + ) + } + } + +
+ { name } + + { sequences.map(({sequence}) => {sequence}) } + + +
+
+ ); + } else if (this.state.changingActionShortcut) { + const { cancel } = this.state.changingActionShortcut; + + const keyMap = getApplicationKeyMap(); + const { name } = keyMap[this.state.changingActionShortcut]; + + return ( +
+ Press the keys you would like to bind to #{name}. + + +
+ ); + } +} + +showChangeShortcutDialog(actionName) { + const cancelListening = recordKeyCombination(({id}) => { + this.setState({ + showShortcutsDialog: true, + changingActionShortcut: null, + keyMap: { + ...this.state.keyMap, + [actionName]: id + } + }); + }); + + this.setState({ + showShortcutsDialog: false, + changingActionShortcut: { + cancel: () => { + cancelListening(); + + this.setState({ + showShortcutsDialog: true, + changingActionShortcut: null + }); + } + } + }); +} +``` + +If you are updating hotkeys without changing focus or remounting the component that defines them, you will need to make sure you use the [`allowChanges` prop](#hotkeys-component-api) to ensure the new keymaps are honoured immediately. + ## Defining Handlers Key maps trigger actions when they match a key sequence. Handlers are the functions that `react-hotkeys` calls to handle those actions. @@ -742,39 +857,39 @@ import { getApplicationKeyMap } from 'react-hotkeys'; // ... -renderDialog() { - if (this.state.showDialog) { - const keyMap = getApplicationKeyMap(); - - return ( -
-

- Keyboard shortcuts -

- - - - { Object.keys(keyMap).reduce((memo, actionName) => { - const { sequences, name } = keyMap[actionName]; - - memo.push( - - - - - ) - } - } - -
- { name } - - { sequences.map(({sequence}) => {sequence}) } -
-
- ); - } +renderDialog(){ + if (this.state.showDialog) { + const keyMap = getApplicationKeyMap(); + + return ( +
+

+ Keyboard shortcuts +

+ + + + { Object.keys(keyMap).reduce((memo, actionName) => { + const { sequences, name } = keyMap[actionName]; + + memo.push( + + + + + ) + } + } + +
+ { name } + + { sequences.map(({sequence}) => {sequence}) } +
+
+ ); } +} ``` ## Allowing hotkeys and handlers props to change diff --git a/index.d.ts b/index.d.ts index 225b9b37..b22ead65 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,6 +3,7 @@ import * as React from 'react'; export type MouseTrapKeySequence = string | Array; export type ActionName = string; +export type KeyName = string; export type KeyEventName = 'keyup' | 'keydown' | 'keypress'; @@ -203,6 +204,33 @@ export type ApplicationKeyMap = { [key in ActionName]: KeyMapDisplayOptions }; */ export declare function getApplicationKeyMap(): ApplicationKeyMap; +/** + * Description of key combination passed to the callback registered with + * the recordKeyCombination function + */ +export interface KeyCombination { + /** + * Id of combination that could be used to define a keymap + */ + id: MouseTrapKeySequence; + /** + * Dictionary of keys involved in the combination + */ + keys: { [key in KeyName]: true }; +} + +/** + * Function to call to cancel listening to the next key combination + */ +declare type cancelKeyCombinationListener = () => void; + +/** + * Adds a listener function that will be called the next time a key combination completes + * Returns a function to cancel listening. + */ +export declare function recordKeyCombination(callbackFunction: (keyCombination: KeyCombination) => void): cancelKeyCombinationListener; + + export interface ConfigurationOptions { /** * The level of logging of its own behaviour React HotKeys should perform. Default diff --git a/src/index.js b/src/index.js index e3ec15d5..e1b3052c 100644 --- a/src/index.js +++ b/src/index.js @@ -9,4 +9,5 @@ export {default as withObserveKeys} from './withObserveKeys'; export {default as configure} from './configure'; export {default as getApplicationKeyMap} from './getApplicationKeyMap'; +export {default as recordKeyCombination} from './recordKeyCombination'; diff --git a/src/lib/KeyEventManager.js b/src/lib/KeyEventManager.js index 55e800a0..3e60a138 100644 --- a/src/lib/KeyEventManager.js +++ b/src/lib/KeyEventManager.js @@ -173,6 +173,13 @@ class KeyEventManager { this._globalEventStrategy.deregisterKeyMap(componentId); } + /******************************************************************************** + * Recording key combination + ********************************************************************************/ + addKeyCombinationListener(callbackFunction) { + return this._globalEventStrategy.addKeyCombinationListener(callbackFunction); + } + /******************************************************************************** * Focus key events ********************************************************************************/ diff --git a/src/lib/strategies/AbstractKeyEventStrategy.js b/src/lib/strategies/AbstractKeyEventStrategy.js index 5ed9b5e6..59cbaf45 100644 --- a/src/lib/strategies/AbstractKeyEventStrategy.js +++ b/src/lib/strategies/AbstractKeyEventStrategy.js @@ -747,6 +747,19 @@ class AbstractKeyEventStrategy { this.keyCombinationIncludesKeyUp = false; } + /** + * Whether there are any keys in the current combination still being pressed + * @protected + * @return {Boolean} True if all keys in the current combination are released + */ + _allKeysAreReleased() { + const currentCombination = this._getCurrentKeyCombination(); + + return Object.keys(currentCombination.keys).every((keyName) => { + return !this._keyIsCurrentlyDown(keyName); + }); + } + /** * Returns a new KeyCombinationRecord without the keys that have been * released (had the keyup event recorded). Essentially, the keys that are diff --git a/src/lib/strategies/GlobalKeyEventStrategy.js b/src/lib/strategies/GlobalKeyEventStrategy.js index 47a314f0..52c1e5c6 100644 --- a/src/lib/strategies/GlobalKeyEventStrategy.js +++ b/src/lib/strategies/GlobalKeyEventStrategy.js @@ -14,6 +14,7 @@ import describeKeyEvent from '../../helpers/logging/describeKeyEvent'; import isCmdKey from '../../helpers/parsing-key-maps/isCmdKey'; import EventResponse from '../../const/EventResponse'; import contains from '../../utils/collection/contains'; +import dictionaryFrom from '../../utils/object/dictionaryFrom'; /** * Defines behaviour for dealing with key maps defined in global HotKey components @@ -45,6 +46,12 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy { this.eventOptions = { ignoreEventsCondition: Configuration.option('ignoreEventsCondition') }; + + /** + * Dictionary of listener functions - currently only intended to house + * keyCombinationListener + */ + this.listeners = {}; } /******************************************************************************** @@ -201,7 +208,7 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy { } _updateDocumentHandlers(){ - const listenersShouldBeBound = this.componentList.length > 0; + const listenersShouldBeBound = this._listenersShouldBeBound(); if (!this.listenersBound && listenersShouldBeBound) { for(let recordIndex = 0; recordIndex < this.keyMapEventRecord.length; recordIndex++) { @@ -236,6 +243,10 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy { } } + _listenersShouldBeBound() { + return this.componentList.length > 0 || this.listeners.keyCombination; + } + /******************************************************************************** * Recording key events ********************************************************************************/ @@ -505,6 +516,15 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy { * of whether the event should be ignored or not */ this._simulateKeyUpEventsHiddenByCmd(event, key); + + if (this.listeners.keyCombination && this._allKeysAreReleased()) { + const {keys,ids} = this._getCurrentKeyCombination(); + + this.listeners.keyCombination({ + keys: dictionaryFrom(Object.keys(keys), true), + id: ids[0] + }); + } } _simulateKeyPressesMissingFromBrowser(event, key) { @@ -653,6 +673,33 @@ class GlobalKeyEventStrategy extends AbstractKeyEventStrategy { } } + /******************************************************************************** + * Recording key combination + ********************************************************************************/ + + /** + * Add a new key combination listener function to be called the next time a key + * combination completes (assuming the cancel function is not called). + * @param {keyCombinationListener} callbackFunction Function to call with the next + * completed key combination + * @returns {function} Function to call to cancel listening for the next key + * combination + */ + addKeyCombinationListener(callbackFunction) { + const cancel = () => { + delete this.listeners.keyCombination; + }; + + this.listeners.keyCombination = (keyCombination) => { + callbackFunction(keyCombination); + cancel(); + }; + + this._updateDocumentHandlers(); + + return cancel; + } + /******************************************************************************** * Logging ********************************************************************************/ diff --git a/src/recordKeyCombination.js b/src/recordKeyCombination.js new file mode 100644 index 00000000..3224326f --- /dev/null +++ b/src/recordKeyCombination.js @@ -0,0 +1,18 @@ +import KeyEventManager from './lib/KeyEventManager'; + +/** + * @callback keyCombinationListener + */ + +/** + * Adds a listener function that will be called the next time a key combination completes + * @param {keyCombinationListener} callbackFunction Listener function to be called + * @returns {function} Function to call to cancel listening to the next key combination + */ +function recordKeyCombination(callbackFunction) { + const eventManager = KeyEventManager.getInstance(); + + return eventManager.addKeyCombinationListener(callbackFunction); +} + +export default recordKeyCombination; diff --git a/test/GlobalHotKeys/ClosingHangingCombinationsInHotKeysComponents.spec.js b/test/GlobalHotKeys/ClosingHangingCombinationsInHotKeysComponents.spec.js index d377da8a..21046488 100644 --- a/test/GlobalHotKeys/ClosingHangingCombinationsInHotKeysComponents.spec.js +++ b/test/GlobalHotKeys/ClosingHangingCombinationsInHotKeysComponents.spec.js @@ -11,7 +11,7 @@ import KeyCode from '../support/Key'; import {HotKeys, GlobalHotKeys} from '../../src'; -describe('ClosingHangingCombinationsInHotKeysComponents:', function () { +describe('Closing hanging combinations in HotKeys Components:', function () { describe('when a HotKeys component has a handler on keydown that changes the focus to outside its descendants', function () { beforeEach(function () { this.keyMap = { diff --git a/test/recordKeyCombination/addingKeyCombinationListener.spec.js b/test/recordKeyCombination/addingKeyCombinationListener.spec.js new file mode 100644 index 00000000..eeacd68a --- /dev/null +++ b/test/recordKeyCombination/addingKeyCombinationListener.spec.js @@ -0,0 +1,267 @@ +import React from 'react'; +import {mount} from 'enzyme'; +import {expect} from 'chai'; +import sinon from 'sinon'; +import simulant from 'simulant'; + +import {HotKeys, GlobalHotKeys,recordKeyCombination} from '../../src/'; +import KeyCode from '../support/Key'; +import FocusableElement from '../support/FocusableElement'; + +describe('Adding key combination listener:', () => { + context('when only a HotKeys component is mounted', () => { + context('without any keyMap or handlers', () => { + beforeEach(function () { + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + it('calls the combination listener', function() { + const callback = sinon.spy(); + recordKeyCombination(callback); + + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + + expect(callback).to.not.have.been.called; + + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(callback).to.have.been.calledOnce; + + expect(callback).to.have.been.calledWithMatch({ + keys: { a: true }, + id: 'a' + }); + }); + }); + + context('with a keyMap', () => { + beforeEach(function () { + this.keyMap = { + 'ACTION1': 'a' + }; + + this.handler = sinon.spy(); + + this.handlers = { + 'ACTION1': this.handler, + }; + + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + + this.firstElement = new FocusableElement(this.wrapper, '.childElement'); + this.firstElement.focus(); + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + it('then calls the combination listener after any matching handlers', function() { + const callback = sinon.spy(); + recordKeyCombination(callback); + + this.firstElement.keyDown(KeyCode.A); + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + + this.firstElement.keyPress(KeyCode.A); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + + this.firstElement.keyUp(KeyCode.A); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(this.handler).to.have.been.calledOnce; + expect(callback).to.have.been.calledOnce; + + expect(callback).to.have.been.calledAfter(this.handler); + }); + }); + }); + + context('when only a GlobalHotKeys component is mounted', () => { + context('without any keyMap or handlers', () => { + beforeEach(function () { + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + it('then calls the combination listener', function() { + const callback = sinon.spy(); + recordKeyCombination(callback); + + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + + expect(callback).to.not.have.been.called; + + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(callback).to.have.been.calledOnce; + + expect(callback).to.have.been.calledWithMatch({ + keys: { a: true }, + id: 'a' + }); + }); + }); + + context('with a keyMap', () => { + beforeEach(function () { + this.keyMap = { + 'ACTION1': 'a' + }; + + this.handler = sinon.spy(); + + this.handlers = { + 'ACTION1': this.handler, + }; + + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + it('then calls the combination listener after any matching handlers', function() { + const callback = sinon.spy(); + recordKeyCombination(callback); + + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(this.handler).to.have.been.calledOnce; + expect(callback).to.have.been.calledOnce; + + expect(callback).to.have.been.calledAfter(this.handler); + }); + }); + }); + + context('when the cancel function is called', () => { + beforeEach(function () { + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + + this.callback = sinon.spy(); + const cancel = recordKeyCombination(this.callback); + + cancel(); + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + it('then doesn\'t call the key combination listener', function() { + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(this.callback).to.not.have.been.called; + }); + }); + + context('when the listener has already been called', () => { + beforeEach(function () { + this.reactDiv = document.createElement('div'); + document.body.appendChild(this.reactDiv); + + this.wrapper = mount( + +
+ , + { attachTo: this.reactDiv } + ); + + this.callback = sinon.spy(); + recordKeyCombination(this.callback); + + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.A }); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.A }); + + expect(this.callback).to.have.been.called; + }); + + afterEach(function() { + document.body.removeChild(this.reactDiv); + }); + + context('and it\'s not rebound', () => { + it('then doesn\'t call the key combination listener again', function() { + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.B }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.B }); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.B }); + + expect(this.callback).to.have.been.calledOnce; + }); + }); + + context('and it\'s rebound', () => { + beforeEach(function () { + this.newCallback = sinon.spy(); + recordKeyCombination(this.newCallback); + }); + + it('then calls the new key combination listener', function() { + simulant.fire(this.reactDiv, 'keydown', { key: KeyCode.B }); + simulant.fire(this.reactDiv, 'keypress', { key: KeyCode.B }); + simulant.fire(this.reactDiv, 'keyup', { key: KeyCode.B }); + + expect(this.newCallback).to.have.been.calledWithMatch({ + keys: { b: true }, + id: 'b' + }); + }); + }); + }); +});