diff --git a/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx b/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx
index c167de5d7..abd9f16f0 100644
--- a/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx
+++ b/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx
@@ -1,7 +1,7 @@
import { h } from 'preact';
import { renderHook } from '@testing-library/preact-hooks';
import { fireEvent, render } from '@testing-library/preact';
-import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts.jsx';
+import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts';
describe('Keyboard shortcuts for components', () => {
describe('useKeyboardShortcuts', () => {
@@ -18,6 +18,40 @@ describe('Keyboard shortcuts for components', () => {
expect(shortcut.KeyK).toHaveBeenCalledTimes(1);
});
+ it('should fire a function when chained keydown is detected', () => {
+ const shortcut = {
+ "KeyA~KeyB": jest.fn()
+ };
+
+ renderHook(() =>
+ useKeyboardShortcuts(shortcut, document),
+ );
+ fireEvent.keyDown(document, { code: "KeyA" });
+ fireEvent.keyDown(document, { code: "KeyB" });
+
+ expect(shortcut["KeyA~KeyB"]).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not fire a function when chained keydown is missed, timeout should only be 0ms', async () => {
+ const shortcut = {
+ "KeyA~KeyB": jest.fn()
+ };
+
+ const timeout = 0;
+
+ renderHook(() =>
+ useKeyboardShortcuts(shortcut, document, { timeout }),
+ );
+ fireEvent.keyDown(document, { code: "KeyA" });
+
+ await new Promise(resolve => setTimeout(() => {
+ fireEvent.keyDown(document, { code: "KeyB" });
+ resolve();
+ }, 25));
+
+ expect(shortcut["KeyA~KeyB"]).not.toHaveBeenCalled();
+ });
+
it('should not add event listener if shortcut object is empty', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
@@ -30,11 +64,13 @@ describe('Keyboard shortcuts for components', () => {
it('should add event listener to window', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
+
+ const shortcut = {
+ KeyK: null
+ }
renderHook(() =>
- useKeyboardShortcuts({
- KeyK: null
- }, document),
+ useKeyboardShortcuts(shortcut, document),
);
expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1);
@@ -58,9 +94,13 @@ describe('Keyboard shortcuts for components', () => {
it('should remove event listener when the hook is unmounted', () => {
HTMLDocument.prototype.addEventListener = jest.fn();
HTMLDocument.prototype.removeEventListener = jest.fn();
+
+ const shortcut = {
+ KeyK: null
+ }
const { unmount } = renderHook(() =>
- useKeyboardShortcuts({ KeyK: null }, document),
+ useKeyboardShortcuts(shortcut, document),
);
unmount();
diff --git a/app/javascript/shared/components/useKeyboardShortcuts.js b/app/javascript/shared/components/useKeyboardShortcuts.js
new file mode 100644
index 000000000..e541e189f
--- /dev/null
+++ b/app/javascript/shared/components/useKeyboardShortcuts.js
@@ -0,0 +1,168 @@
+import { useState, useEffect, useCallback } from "preact/hooks";
+import PropTypes from "prop-types";
+
+/**
+ * Checker that return true if element is a form element
+ *
+ * @param {node} element to be checked
+ *
+ * @returns {boolean} isFormField
+ */
+function isFormField(element) {
+ if (element instanceof HTMLElement === false) return false;
+
+ const name = element.nodeName.toLowerCase();
+ const type = (element.getAttribute("type") || "").toLowerCase();
+ return (
+ name === "select" ||
+ name === "textarea" ||
+ (name === "input" && type !== 'submit' && type !== 'reset' && type !== 'checkbox' && type !== 'radio') ||
+ element.isContentEditable
+ );
+}
+
+// Default options to be used if null
+const defaultOptions = {
+ timeout: 500
+};
+
+/**
+ * hook that can be added to a component to listen
+ * for keyboard presses
+ *
+ * @example
+ * const shortcuts = {
+ * "ctrl+alt+KeyG": (e) => {
+ * e.preventDefault();
+ * alert("Control Alt G has been pressed");
+ * },
+ * "KeyG~KeyH": (e) => {
+ * e.preventDefault();
+ * alert("G has been pressed quickly followed by H");
+ * },
+ * "?": (e) => {
+ * setIsHelpVisible(true);
+ * }
+ * }
+ *
+ * useKeyboardShortcuts(shortcuts, someElementOrWindowObject, {timeout: 1500});
+ *
+ * @param {object} shortcuts List of keyboard shortcuts/event
+ * @param {EventTarget} [eventTarget=window] An event target.
+ * @param {object} [options = {}] An object for extra options
+ *
+ */
+export function useKeyboardShortcuts( shortcuts, eventTarget = window, options = {} ) {
+ const [keyChain, setKeyChain] = useState([]);
+ const [keyChainQueue, setKeyChainQueue] = useState(null);
+ const [mergedOptions, setMergedOptions] = useState({ ...defaultOptions, ...options });
+
+ // Work out the correct shortcut for the key press
+ const callShortcut = useCallback((e, keys) => {
+ let shortcut;
+ if (keyChain.length > 0) {
+ shortcut = shortcuts[`${keyChain.join("~")}~${e.code}`];
+ } else {
+ shortcut = shortcuts[`${keys}${e.code}`] || shortcuts[`${keys}${e.key.toLowerCase()}`];
+ }
+
+ // if a valid shortcut is found call it and reset the chain
+ if (shortcut) {
+ shortcut(e);
+ setKeyChain([]);
+ }
+ }, [shortcuts, keyChain]);
+
+ // update mergedOptions if options prop changes
+ useEffect(() => {
+ const newOptions = {};
+ if (typeof options.timeout === "number") newOptions.timeout = options.timeout;
+ setMergedOptions({ ...defaultOptions, ...newOptions });
+ }, [options.timeout]);
+
+ // Set up key chains
+ useEffect(() => {
+ if (!keyChainQueue && keyChain.length === 0) return;
+ let timeout;
+
+ timeout = window.setTimeout(() => {
+ clearTimeout(timeout);
+ setKeyChain([]);
+ }, mergedOptions.timeout);
+
+ if (keyChainQueue) {
+ setKeyChain([...keyChain, keyChainQueue]);
+ setKeyChainQueue(null);
+ }
+
+ return () => clearTimeout(timeout);
+ }, [keyChain, keyChainQueue, mergedOptions.timeout]);
+
+ // set up event listeners
+ useEffect(() => {
+ if (!shortcuts || Object.keys(shortcuts).length === 0) return;
+
+ const keyEvent = (e) => {
+ if (e.defaultPrevented) return;
+
+ // Get special keys
+ const keys = `${e.ctrlKey || e.metaKey ? "ctrl+" : ""}${e.altKey ? "alt+" : ""}${(e.ctrlKey || e.metaKey || e.altKey) && e.shiftKey ? "shift+" : ""}`;
+
+ // If no special keys, except shift, are pressed and focus is inside a field return
+ if (e.target instanceof Node && isFormField(e.target) && !keys) return;
+
+ // If a special key is pressed reset the key chain else add to the chain
+ if (keys) {
+ setKeyChain([]);
+ } else {
+ setKeyChainQueue(e.code);
+ }
+
+ callShortcut(e, keys);
+ };
+
+ eventTarget.addEventListener("keydown", keyEvent);
+
+ return () => eventTarget.removeEventListener("keydown", keyEvent);
+ }, [shortcuts, eventTarget, callShortcut]);
+}
+
+/**
+ * A component that can be added to a component to listen
+ * for keyboard presses using the useKeyboardShortcuts hook
+ *
+ * @example
+ * const shortcuts = {
+ * "ctrl+alt+KeyG": (e) => {
+ * e.preventDefault();
+ * alert("Control Alt G has been pressed")
+ * }
+ * }
+ *
+ *
+ *
+ *
+ * @param {object} shortcuts List of keyboard shortcuts/event
+ * @param {EventTarget} [eventTarget=window] An event target.
+ * @param {object} [options = {}] An object for extra options
+ *
+ */
+export function KeyboardShortcuts({ shortcuts, eventTarget, options }) {
+ useKeyboardShortcuts(shortcuts, eventTarget, options);
+
+ return null;
+}
+
+KeyboardShortcuts.propTypes = {
+ shortcuts: PropTypes.object.isRequired,
+ options: PropTypes.shape({
+ timeout: PropTypes.number
+ }),
+ eventTarget: PropTypes.instanceOf(Element)
+};
+
+KeyboardShortcuts.defaultProps = {
+ shortcuts: {},
+ options: {},
+ eventTarget: window
+};
diff --git a/app/javascript/shared/components/useKeyboardShortcuts.jsx b/app/javascript/shared/components/useKeyboardShortcuts.jsx
deleted file mode 100644
index d77ea0c30..000000000
--- a/app/javascript/shared/components/useKeyboardShortcuts.jsx
+++ /dev/null
@@ -1,104 +0,0 @@
-import { useEffect } from "preact/hooks";
-import PropTypes from 'prop-types';
-import { h } from 'preact';
-
-/**
- * Checker that return true if element is a form element
- *
- * @param {node} element to be checked
- *
- * @returns {boolean} isFormField
- */
-function isFormField(element) {
- if ((element instanceof HTMLElement) === false) return false;
-
- const name = element.nodeName.toLowerCase();
- const type = (element.getAttribute("type") || "").toLowerCase();
- return (
- name === "select" ||
- name === "textarea" ||
- (name === "input" && ["submit", "reset", "checkbox", "radio"].indexOf(type) < 0) ||
- element.isContentEditable
- );
-};
-
-/**
- * hook that can be added to a component to listen
- * for keyboard presses
- *
- * @example
- * const shortcuts = {
- * "ctrl+alt+KeyG": (e) => {
- * e.preventDefault();
- * alert("Control Alt G has been pressed");
- * },
- * "?": (e) => {
- * setIsHelpVisible(true);
- * }
- * }
- *
- * useKeyboardShortcuts(shortcuts, someElementOrWindowObject);
- *
- * @param {object} shortcuts List of keyboard shortcuts/event
- * @param {EventTarget} [eventTarget=window] An event target.
- *
- */
-export function useKeyboardShortcuts(shortcuts, eventTarget = window) {
- useEffect(() => {
- if (!shortcuts || Object.keys(shortcuts).length === 0) return;
-
- const keyEvent = (e) => {
- if (e.defaultPrevented) return;
-
- // Get special keys
- const keys = `${e.ctrlKey || e.metaKey ? "ctrl+" : ""}${e.altKey ? "alt+" : ""}${e.shiftKey ? "shift+" : ""}`;
-
- // If no special keys are pressed and focus is inside a field return
- if (e.target instanceof Node && isFormField(e.target) && !keys) return;
-
- const shortcut = shortcuts[`${keys}${e.code}`] || shortcuts[`${keys}${e.key.toLowerCase()}`];
- if (shortcut) shortcut(e);
- };
-
- eventTarget.addEventListener("keydown", keyEvent);
-
- return () => {
- eventTarget.removeEventListener("keydown", keyEvent);
- };
- }, [shortcuts, eventTarget]);
-}
-
-/**
- * compoent that can be added to a component to listen
- * for keyboard presses using the useKeyboardShortcuts hook
- *
- * @example
- * const shortcuts = {
- * "ctrl+alt+KeyG": (e) => {
- * e.preventDefault();
- * alert("Control Alt G has been pressed")
- * }
- * }
- *
- *
- *
- *
- * @param {object} shortcuts List of keyboard shortcuts/event
- * @param {EventTarget} [eventTarget=window] An event target.
- *
- */
-export function KeyboardShortcuts({ shortcuts, eventTarget }) {
- useKeyboardShortcuts(shortcuts, eventTarget);
-
- return null;
-}
-
-KeyboardShortcuts.propTypes = {
- shortcuts: PropTypes.object.isRequired,
- eventTarget: PropTypes.instanceOf(Element)
-}
-
-KeyboardShortcuts.defaultProps = {
- shortcuts: {},
- eventTarget: window
-}