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} ) }
+
+
+ this.showChangeShortcutDialog(actionName) }>
+ Change
+
+
+
+ )
+ }
+ }
+
+
+
+ );
+ } 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}.
+
+
+ Cancel
+
+
+ );
+ }
+}
+
+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'
+ });
+ });
+ });
+ });
+});