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 -}