From b7dfb2495a571ee677c52094a7d75476d636047d Mon Sep 17 00:00:00 2001 From: Andrew Bone Date: Thu, 22 Oct 2020 12:45:42 +0100 Subject: [PATCH] Allow keyboard shortcuts (#10713) * Create keyboardShortcuts.jsx Add handler for keyboard shortcuts * Match github's hotkey style Makes objects clearer. * Update keyboardShortcuts.jsx * Allow single character shortcuts update to allow single character shortcuts/hotkeys * Add component documentation Add inline documentation for the component. * Fix example typo * Move keyboardShortcuts to a hook * Fixed useKeyboardShortcuts example * Rename keyboardShortcuts.jsx to useKeyboardShortcuts.jsx * Rebuild * Move isFormField outside render cycle Move the isFormField function outside of the render cycle. * Prevent event listener on empty object Don't add an event listener if the shortcuts property is an empty object or is null * Add tests for useKeyboardShortcuts Initial pass for adding tests to useKeyboardShortcuts * Missed unmount Missed a whole word... * Fix typos * Convert function type * Add new test cases. * Missing brackets * Typo * Update useKeyboardShortcuts.test.jsx * Move event listener to document * Update tests * Use testing-library/user-event * Correct fireEvent * Update keyDown * Slight tidy * Change order, just in case * Swap to render * Make function async * Rerun travis * Add rerender * Fix render function * check event.key for fallback --- .../__tests__/useKeyboardShortcuts.test.jsx | 107 ++++++++++++++++++ .../components/useKeyboardShortcuts.jsx | 104 +++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx create mode 100644 app/javascript/shared/components/useKeyboardShortcuts.jsx diff --git a/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx b/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx new file mode 100644 index 000000000..c167de5d7 --- /dev/null +++ b/app/javascript/shared/components/__tests__/useKeyboardShortcuts.test.jsx @@ -0,0 +1,107 @@ +import { h } from 'preact'; +import { renderHook } from '@testing-library/preact-hooks'; +import { fireEvent, render } from '@testing-library/preact'; +import { KeyboardShortcuts, useKeyboardShortcuts } from '../useKeyboardShortcuts.jsx'; + +describe('Keyboard shortcuts for components', () => { + describe('useKeyboardShortcuts', () => { + it('should fire a function when keydown is detected', () => { + const shortcut = { + KeyK: jest.fn() + }; + + renderHook(() => + useKeyboardShortcuts(shortcut, document), + ); + fireEvent.keyDown(document, { code: "KeyK" }); + + expect(shortcut.KeyK).toHaveBeenCalledTimes(1); + }); + + it('should not add event listener if shortcut object is empty', () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + + renderHook(() => + useKeyboardShortcuts({}, document), + ); + + expect(HTMLDocument.prototype.addEventListener).not.toHaveBeenCalled(); + }); + + it('should add event listener to window', () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + + renderHook(() => + useKeyboardShortcuts({ + KeyK: null + }, document), + ); + + expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1); + }); + + it('should not fire a function when keydown is detected in element', () => { + const shortcut = { + KeyK: jest.fn() + }; + const eventTarget = document.createElement('textarea') // eventTarget set since the default is window + + renderHook(() => + useKeyboardShortcuts(shortcut, document), + eventTarget, + ); + fireEvent.keyDown(eventTarget, { code: "KeyK" }); + + expect(shortcut.KeyK).not.toHaveBeenCalled(); + }); + + it('should remove event listener when the hook is unmounted', () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + HTMLDocument.prototype.removeEventListener = jest.fn(); + + const { unmount } = renderHook(() => + useKeyboardShortcuts({ KeyK: null }, document), + ); + + unmount(); + + expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1); + expect(HTMLDocument.prototype.removeEventListener).toHaveBeenCalledTimes(1); + }); + }); + + describe('', () => { + it('should not add event listener if shortcut object is empty', async () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + + render( + , + ); + + expect(HTMLDocument.prototype.addEventListener).not.toHaveBeenCalled(); + }); + + it('should add event listener to window', async () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + + render( + , + ); + + expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1); + }); + + it('should remove event listener when the hook is unmounted', async () => { + HTMLDocument.prototype.addEventListener = jest.fn(); + HTMLDocument.prototype.removeEventListener = jest.fn(); + + const { unmount } = render( + , + ); + + unmount(); + expect(HTMLDocument.prototype.addEventListener).toHaveBeenCalledTimes(1); + expect(HTMLDocument.prototype.removeEventListener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/javascript/shared/components/useKeyboardShortcuts.jsx b/app/javascript/shared/components/useKeyboardShortcuts.jsx new file mode 100644 index 000000000..d77ea0c30 --- /dev/null +++ b/app/javascript/shared/components/useKeyboardShortcuts.jsx @@ -0,0 +1,104 @@ +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 +}