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